This guide shows a visitor how to point their own AI agent — Claude Code, Codex,
or a claude.ai custom connector — at the helloscope MCP server, read the same real
machine artifacts the museum reads, and replicate the analysis for themselves. The
worked example is a question the museum already answers: "why is hello smaller
than true?"
Everything below was produced by actually calling the server (User-Agent:
helloscope-doc). The request/response shapes are copied from live output, not
invented.
1. What this is
Behind one authless URL —
https://helloscope-live.iunknown.workers.dev/mcp
— sits an MCP server that exposes two things:
- The live hello room in the ELF Atlas (
build/hello): six tools that let an agent drive the on-screen room as a docent. Reading tool:get_agent_guide. - The Reference Linux Archive: four stateless tools (
archive_*) over a pinned glibc snapshot — byte-canonical GNU objdump disassembly and DWARF-mapped C source for every exported function oflibc.so.6andld-linux-x86-64.so.2, keyed by build-id. No session, no auth, no join.
The honesty contract. These tools are built to be cited, not paraphrased. The discipline that the museum's own published exhibits follow, and that your agent should follow too:
- Cite an address, size, or instruction only from a tool's own output — never from training memory.
- A disassembly row with no
── file:line ──header above it carries no source claim. It means "DWARF maps nothing here," not "no source exists." Don't invent a line for it. archive_calleesclassifies every outgoing edge. Report aninto_bodycallee as the tool gives it (<name>+0xOFF, a non-exported internal) — never under the bare containing name. Report anindirectcallee as not statically resolvable.- Every archive result carries its own
provenance(package version, origin, objdump version) andbuild_id. Repeat that frame when you cite.
2. Connect your agent
(a) Claude Code
claude mcp add --transport http helloscope https://helloscope-live.iunknown.workers.dev/mcp
Then just ask: "Using the helloscope MCP server, look up the archived binaries and
tell me libc's build-id." The archive_* tools need no join_session — they answer
immediately. (join_session is only for driving the live room page; see the guide.)
(b) Codex
codex mcp add helloscope --url https://helloscope-live.iunknown.workers.dev/mcp
Gotcha, documented honestly. In an interactive codex session this works after
you approve the tool once. But a non-interactive codex exec run cannot approve MCP
tool calls — it will refuse the call unless you either (i) run interactively and
approve, or (ii) bypass the approval/sandbox layer explicitly:
codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox \
"Using the helloscope MCP server, run archive_lookup and report the two build-ids."
This is exactly how the museum drives Codex as a clean-room fact-checker (below). The bypass flag is only safe here because the tools are read-only network calls — the server has no write surface a visitor can reach.
(c) claude.ai custom connector
In claude.ai → Settings → Connectors → Add custom connector, paste the URL
https://helloscope-live.iunknown.workers.dev/mcp. It is an authless remote MCP server,
so there is no OAuth step and no API key — the connector attaches and the eleven tools
appear in the tool tray. Ask your questions in the chat.
(d) Raw curl (any agent, or just you)
The transport is JSON-RPC over HTTP. initialize mints an Mcp-Session-Id header you
echo on every later request:
# 1. Handshake — capture the session id from the response header.
curl -sS -D - -o /dev/null -X POST https://helloscope-live.iunknown.workers.dev/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"me","version":"1"}}}' | grep -i mcp-session-id
# → mcp-session-id: dcea25ff-f151-43f6-a5d0-16f183f9c35f
The initialize result identifies the server as helloscope-live 9.0.0 and returns
an instructions string describing the two validation regimes. Every subsequent call
adds -H "Mcp-Session-Id: <that id>". To end cleanly, DELETE /mcp with the same
header (replies 204). Archive tools don't strictly need the session id, but sending
it is harmless.
3. The eleven tools
tools/list returns eleven. Four are the stateless archive tools; seven drive the
live room.
The four archive tools
Every one takes a build_id (from archive_lookup) and returns a JSON text block that
includes provenance and build_id.
archive_lookup({query?}) — no arg lists the archived binaries; a soname or
build-id summarizes one. Real (trimmed):
// archive_lookup({})
{
"archive": "Reference Linux Archive",
"provenance": {
"origin": "Arch Linux glibc 2.43+r22+g8362e8ce10b2-2; debug package recovered by
bit-exact reproducible rebuild (…)",
"objdump": "GNU objdump (GNU Binutils) 2.46.0", "pyelftools": "0.33" },
"binaries": [
{ "build_id": "8f271b1884fa6225b5ac96a6bcb7326a5f1f25ec",
"soname": "ld-linux-x86-64.so.2",
"counts": { "exports": 22, "fn_objects": 22, "sources": 15 } },
{ "build_id": "020d6f7c33b2413f4fe10814c4729dce1387f049",
"soname": "libc.so.6",
"counts": { "exports": 2956, "fn_objects": 2323, "sources": 1564 } } ]
}
archive_function({build_id, name, start_row?, max_rows?}) — a function's real
disassembly as addr: bytes text rows under ── file:line ── DWARF group headers.
Windowed (200 rows default, 500 cap). Real (trimmed):
// archive_function({build_id:"020d6f7c…", name:"exit"})
{ "name": "exit", "version": "GLIBC_2.2.5", "aliases": "exit@GLIBC_2.2.5 (default)",
"addr": { "hex": "0x411c0", "dec": 266688 }, "size": 30, "type": "FUNC",
"source_files": ["stdlib/exit.c"],
"window": { "start_row": 0, "count": 7, "total": 7 },
"listing": "── stdlib/exit.c:147 ──\nexit:\n 411c0: f3 0f 1e fa endbr64\n
411c4: 55 push %rbp\n── stdlib/exit.c:148 ──\n
411c5: b9 01 00 00 00 mov $0x1,%ecx\n … \n
411d9: e8 32 fd ff ff call 40f10 <erand48_r@@GLIBC_2.2.5+0x70>",
"note": "… a run of rows with NO header carries no source claim (not 'no source'). …" }
archive_source({build_id, path, start_line?, end_line?}) — verbatim glibc source,
numbered NNN→text, cap 400 lines/call. Real (trimmed):
// archive_source({build_id:"020d6f7c…", path:"stdlib/exit.c", start_line:145, end_line:160})
{ "path": "stdlib/exit.c",
"lines": { "start": 145, "end": 150, "total": 150 },
"source": " 145→void\n 146→exit (int status)\n 147→{\n
148→ __run_exit_handlers (status, &__exit_funcs, true, true);\n
149→}\n 150→libc_hidden_def (exit)",
"truncated": true, "footer": "showing lines 145..150 of 150." }
archive_callees({build_id, name}) — outgoing control-transfer edges, honestly
classified. Real (trimmed, puts):
// archive_callees({build_id:"020d6f7c…", name:"puts"})
{ "counts": { "direct": 3, "tail": 0, "into_body": 0,
"internal_unresolved": 3, "indirect": 1 },
"edges": [
{ "kind": "direct", "target": "0x915d0",
"names": [{ "name": "__overflow", "version": "GLIBC_2.2.5", "default": true }] },
{ "kind": "internal_unresolved", "target_addr": 148048,
"target": "0x24250 (inside no exported function; no name claimed)" },
{ "kind": "indirect", "target": "not statically resolvable (*0x38(%rax))" } ]
}
The six room-driving tools (one line)
join_session, get_agent_guide, get_state, apply_state, play_step, export_tour — these bind to and narrate the live room page.
get_agent_guide is the one you want even offline: it returns the whole agent guide as
Markdown (the ELF Atlas state grammar and the complete hello fixture facts). Read
it before driving; see exhibit-001-elf-atlas/agent-guide.md for the full contract.
4. Worked example — "why is hello smaller than true?"
Here is the exact sequence an agent runs. Each step ends in a real citation.
Step 1 — get hello's numbers. Call get_agent_guide(). Its FIXTURE FACTS section
states: build/hello is 16,984 bytes; .text (the compiled code) is 275 bytes,
68 instructions; and it imports exactly three libc functions — puts,
__libc_start_main, __cxa_finalize. (Source: get_agent_guide.)
Step 2 — get true's numbers. The true room's fixture is a static asset you can
fetch directly (no MCP needed):
curl -sS -H 'User-Agent: helloscope-doc' \
https://helloscope-live.iunknown.workers.dev/rooms/true/true.json
Its file.size is 43,248 bytes; the .text section is 19,027 bytes; and the
dynamic symbol table has 46 undefined (SHN_UNDEF) FUNC imports — 46 libc
functions it calls, versus hello's 3. (Source: /rooms/true/true.json,
file.size / sections[.text].size / count of dynamic_symbols with
shndx == "SHN_UNDEF" and type == "FUNC".) The companion asset
/rooms/true/imports.libc.json carries the resolved libc bodies of those imports.
(hello's own live page at / embeds its fixture inline rather than serving it as a
separate file.)
Step 3 — see what the extra code is. The 46 imports name the difference:
setlocale, dcgettext, bindtextdomain, textdomain, nl_langinfo, mbrtoc32,
__printf_chk, strerror_r … — locale, message translation, and formatted-output
machinery that a one-line printf("hello, world") never touches. Pull one to size it:
// archive_function({build_id:"020d6f7c…", name:"setlocale", max_rows:6})
{ "name": "setlocale", "addr": { "hex": "0x35920" }, "size": 1902,
"source_files": ["locale/localeinfo.h", "locale/setlocale.c"],
"window": { "total": 389 } }
// archive_function({… name:"dcgettext"}) → size 19 (a thin shim to __dcgettext)
setlocale alone is 1,902 bytes — nearly seven times hello's entire 275-byte
.text. (Source: archive_function, size field.) true links against 46 such
functions; hello links against 3.
Step 4 — the punchline. true's .text section (19,027 bytes) is by itself
larger than hello's whole file (16,984 bytes). The size gap is not overhead — it's
program: true is the GNU coreutils true, carrying argument parsing,
--help/--version, and full internationalization; hello is one puts call.
A good final answer (house style, claim → source)
hellois 16,984 bytes andtrueis 43,248 — about 2.5× larger (both from the fixtures:get_agent_guideFIXTURE FACTS for hello,/rooms/true/true.jsonfile.sizefor true). The gap is real code, not padding:true's.textsection is 19,027 bytes (true.jsonsections[.text].size) — larger than hello's entire file. It shows up in the imports too —truepulls 46 libc functions (count ofSHN_UNDEFFUNCentries intrue.jsondynamic_symbols) against hello's 3 (get_agent_guide). Those extras are internationalization and safe I/O:setlocaleis 1,902 bytes,dcgettexta 19-byte shim into__dcgettext(both fromarchive_functionsize, libc build-id020d6f7c…, glibc 2.43+r22+g8362e8ce10b2-2, GNU objdump 2.46.0).hellois small because it does almost nothing;trueis a real coreutils program with a locale-aware, option-parsing front end.
Every clause names the tool it came from. That is the whole discipline.
5. Replicate on hello itself
The canonical museum question is: what does .text's call 1030 <puts@plt> actually
reach? Ask your agent to trace it end to end using only the tools:
get_agent_guide()— confirmsmainat0x1139callsputs@pltat0x1147, and thatputslives inlibc.so.6, build-id020d6f7c….archive_function({build_id:"020d6f7c…", name:"puts"})— the realputsbody at0x83fc0, 542 bytes, underlibio/ioputs.cline headers.archive_callees({build_id:"020d6f7c…", name:"puts"})— whereputsgoes next: three direct edges (__overflow, and the private lock helpers__lll_lock_wait_private/__lll_lock_wake_private), three internal_unresolved targets (addresses only, no name claimed), and one indirect call (*0x38(%rax)— a vtable dispatch, not statically resolvable).archive_source({build_id:"020d6f7c…", path:"libio/ioputs.c", …})— the C the addresses map to.
A fresh agent given only the MCP URL has answered exactly this question and had every claim adversarially fact-checked against the same tools — the archive's own Stage-2 cold-agent gate. Your agent should reach the same edges, because they come from the same byte-canonical objdump rows.
Note the honest edge case worth teaching your agent: in exit's listing, objdump labels
its one call <erand48_r@@GLIBC_2.2.5+0x70>, but archive_callees reports that target
as internal_unresolved ("inside no exported function; no name claimed") — because the
address lands past the end of erand48_r, not inside it. The tool is deliberately more
honest than the raw objdump label. Never let an agent quote erand48_r here.
6. The honesty gate
Any answer built from these tools is checkable by the same tools — that is the point. To adversarially fact-check any claim (your own agent's, or the museum's):
- Dispatch a second, fresh agent (a different model is better — the museum uses Codex via
codex exec --dangerously-bypass-approvals-and-sandbox) with only the MCP URL and no local file access. - Its job is to refute, not confirm: for every numeric, structural, or behavioral claim, try to disprove it with tool output. Verdict per claim: CONFIRMED (output matches), REFUTED (output contradicts — quote it), or UNVERIFIABLE (no tool can establish it).
- The gate is zero REFUTED. Anything UNVERIFIABLE (ASLR load addresses, lazy-vs-eager binding timing, unnamed IFUNC targets) must be rephrased as not established or dropped.
This is not hypothetical: it is precisely how every published exhibit in this repo
was validated before shipping (see .claude/skills/walkthrough/SKILL.md, Phase 4).
Because the archive is byte-canonical, two agents that cite honestly reach the same
numbers.
7. Limits & provenance
- Pinned snapshot. Everything is one frozen glibc build: Arch Linux glibc
2.43+r22+g8362e8ce10b2-2,libc.so.6build-id020d6f7c33b2413f4fe10814c4729dce1387f049,ld-linux-x86-64.so.2build-id8f271b1884fa6225b5ac96a6bcb7326a5f1f25ec. Disassembly is GNU objdump (GNU Binutils) 2.46.0; DWARF read with pyelftools 0.33. Debug info came from a bit-exact reproducible rebuild of the debug package (the upstream r22 debuginfo was publicly lost). - Only two binaries are in the archive today —
libc.so.6(2,956 exports, 2,323 function bodies, 1,564 sources) andld-linux-x86-64.so.2(22 exports). Ask about a function outside those and there is nothing to serve. Cross-binary call targets that leave libc are reported by address, never guessed. - Fixtures are byte-canonical.
hello's andtrue's room fixtures are derived from the exact committed ELF bytes; the archive rows are re-derivable to byte-equality with a fresh objdump run. That is why an answer traced to a tool's output is stable across agents and across time. - Caching.
/archive/<build-id>/…responses areimmutable(content under a build-id never changes). The two static room assets (/rooms/true/true.json,/rooms/true/imports.libc.json) are served as ordinary Worker assets withCache-Control: public, max-age=0, must-revalidate— always revalidated, so you always fetch the current fixture.