Goal: let another program read Ambient’s sessions, including the one being
recorded, through ambient mcp, per ADR 0016.
Architecture: src/mcp.rs holds a synchronous JSON-RPC 2.0 loop over
newline-delimited stdio with a serve(reader, writer, root) entry point that
tests drive with in-memory buffers. Tools call the same session functions the
CLI verbs call. src/main.rs gains the mcp arm.
Tech Stack: Rust 1.85 floor, serde_json only; no async runtime, no MCP SDK.
Global Constraints
- Wire format: one JSON-RPC 2.0 message per line, UTF-8,
\nterminated; nothing else on stdout. Diagnostics go to stderr. The loop never panics on input: every handler returns aResultand anErrbecomes a JSON-RPC error response. - Protocol version: reply with the client’s
protocolVersionwhen it is one of2025-11-25,2025-06-18,2025-03-26; otherwise reply2025-11-25, the latest this server supports, which is what the lifecycle spec requires for an unsupported request. The 2026-07-28 revision retired theinitializehandshake and is not claimed. - Error classes: a line that is not JSON →
-32700; an object without"jsonrpc":"2.0"or a non-stringmethod→-32600; an unknown method →-32601;tools/callwith missing or non-objectparams/arguments, a non-stringname, an unknown tool name, or an argument of the wrong type →-32602; a tool that ran and failed (no such session, unreadable file) → a result withisError: trueand the message as text. - Every tool result is
{"content":[{"type":"text","text":"<JSON>"}],"isError":false}where the text is the tool’s JSON value serialised compactly. - A session id must be a non-empty single path segment (no
/, not.or..), androot.join(id)must be a directory bysymlink_metadata(a symlink is refused); otherwise the tool errors, and no path outside the root is ever read. Enumeration obeys the same rule: one privatefn session_dirs(root) -> Vec<PathBuf>insrc/mcp.rswalks the root, keeps entries whosesymlink_metadatais a directory, and keeps those holding asession.jsonor a growingaudio/room.native.wav; bothsessionsandstatus.sessionsuse it, so they always agree (session::listfollows symlinks, which is why the walk lives here). The files inside a session are checked the same way: beforeraw.jsonl,edits.jsonlorsession.jsonis read, itssymlink_metadatamust be a regular file, else the tool errors withrefusing symlink <path>(forsessions, that session is listed witherrorset and metadataNone). - This plan lands after the session-tools plan. Where a task consumes
session::summaries,session::live_session_inorLine: Serialize, and they are not onmain, the task adds them exactly as specified in its Interfaces before proceeding. scripts/checkdoes not look at docs. After any docs edit runnode .github/scripts/check-links.mjsand quote its… 0 broken link(s)line.- No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: The JSON-RPC loop with initialize, ping, tools/list and status
Files:
- Create:
src/mcp.rs; registerpub mod mcp;insrc/lib.rsbefore writing the test - Modify:
src/main.rs(newmcparm callingambient::mcp::serve(std::io::stdin().lock(), std::io::stdout().lock(), &ambient::session::home())),USAGE
Interfaces:
-
Consumes:
session::live_session_in(root)(add aspub fn live_session_in(root: &Path) -> Option<PathBuf>, the body oflive_session()withrootin place ofhome(), if absent),session::captured_awaiting_transcript(root),session::list(root). -
Produces:
pub fn serve<R: BufRead, W: Write>(reader: R, writer: W, root: &Path) -> anyhow::Result<()>, returningOk(())at EOF;pub fn handle(line: &str, root: &Path) -> Option<serde_json::Value>(Nonefor notifications, which have noid); the toolstatuswith no arguments returning{"live": {"id": "<dir name>"} | null, "awaiting_transcript": <n>, "sessions": <n>}wheresessionsissession_dirs(root).len(), the set thesessionstool lists;tools/listreturning the three tool descriptorssessions,transcript,statuswithinputSchema(transcript’s schema:sessionstring required,sinceinteger,verbatimboolean; the other two{"type":"object","properties":{}}); until Task 2,tools/callonsessionsortranscriptreturnsisError: truewith textnot implemented. -
Step 1: write the failing tests in
src/mcp.rs’s test module, each feeding a&[u8]of request lines toservewith aVec<u8>writer and a temp root, then parsing each output line:initializewith{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}→ result hasprotocolVersion == "2025-11-25",capabilities.toolsis an object,serverInfo.name == "ambient",serverInfo.version == env!("CARGO_PKG_VERSION").initializewithprotocolVersion: "2026-07-28"→protocolVersion == "2025-11-25".notifications/initialized(noid) → no output line.ping→result == {}with the sameid(a string id"a"round-trips as a string).tools/list→ three tools namedsessions,transcript,status, each withinputSchema.type == "object".tools/callwith{"name":"status","arguments":{}}on a root holding one session dir withsession.jsonandtranscript.md, plus a directorynoteswithout one, plus a symlinkoutsidepointing at a temp directory outside the root that holds asession.json, plus a directoryliveholding only a freshly writtenaudio/room.native.wav→ text parses to{"live":{"id":"live"},"awaiting_transcript":0,"sessions":2}.tools/callwith an unknown name →error.code == -32602.tools/callwith noparams→error.code == -32602; with{"name":5}→-32602.{"id":1,"method":"ping"}(nojsonrpc) →-32600;{"jsonrpc":"2.0","id":1,"method":7}→-32600.- a line that is not JSON → error response with
id: null,error.code == -32700. no/such/method→error.code == -32601.- after any of the error lines above, a following
pingstill answers (the loop survived).
-
Step 2: run
cargo test mcp::, expect FAIL “cannot find functionserve”. -
Step 3: implement: read lines; skip blank ones;
handledispatches onmethod; responses are{"jsonrpc":"2.0","id":<id>,"result":…}or{"jsonrpc":"2.0","id":<id>,"error":{"code":…,"message":…}}.liveinstatuscomes fromlive_session_in(root). -
Step 4: run
scripts/check, expectCHECK OK. Thenprintf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"ping"}' | cargo run -- mcpand quote the line. -
Step 5: commit:
git add src/mcp.rs src/lib.rs src/main.rs src/session.rs && git commit -m "mcp: a stdio JSON-RPC verb"(omitsrc/session.rsif unchanged).
Task 2: The sessions and transcript tools
Files:
- Modify:
src/mcp.rs(implement the two tools) - Modify:
src/session.rs(newpub fn transcript_appended(dir: &Path, verbatim: bool) -> Result<Vec<Line>>next totranscriptatsrc/session.rs:1059; andsummariesorLine: Serializeif missing, see Interfaces)
Interfaces:
-
Consumes:
session::summaries(root: &Path) -> Vec<SessionSummary>where#[derive(Serialize)] pub struct SessionSummary { pub id: String, pub dir: PathBuf, pub name: Option<String>, pub started_at: Option<String>, pub duration_s: Option<f64>, pub transcribed: bool, pub live: bool, pub transcribing: bool, pub error: Option<String> }(newest first; a dir is included when it holdssession.jsonor a growingaudio/room.native.wav, since capture writessession.jsononly after the scratch wavs close; an unparsablesession.jsonis included witherrorset);#[derive(Serialize)]onLine { track, start_ms, end_ms, speaker, text };session::is_growing,session::live_transcriber. Add any that is absent. -
Produces in
src/session.rs:transcript_appended(dir, verbatim), the same fold overraw.jsonlandedits.jsonlastranscriptbut returning lines inraw.jsonlorder, without thesort_by_key(start_ms)atsrc/session.rs:1123; refactor so both share one body with asort: boolargument in a private helper, so they cannot drift. Test insrc/session.rs:raw.jsonlholding aroomline at 5000 then acallline at 0 →transcriptreturns[0, 5000]bystart_ms,transcript_appendedreturns[5000, 0]. -
Produces: tool
sessions(no arguments) → the JSON array of summaries; tooltranscriptwith{"session": "<id>", "since": <u64, optional, default 0>, "verbatim": <bool, optional, default false>}→{"session": "<id>", "state": "<live|transcribing|pending|done>", "next": <u64>, "lines": [<Line>…]}.sinceis a count of lines already seen in append order (the ordertranscript_appendedreturns),linesis every line from indexsinceon, andnextis the total line count, the value to pass back assince. The cursor is append order and not a timestamp becauseraw.jsonlis written room track first, then call track (src/session.rs:852), and the two tracks’start_msclocks are independent, so a time cursor would skip call lines appended after room lines.stateislivewhileaudio/room.native.wavis growing,transcribingwhilelive_transcribernames a live pid,donewhentranscript.mdexists, andpendingotherwise (captured, waiting for the queue). Whenraw.jsonlis absent,linesis empty and the call succeeds; araw.jsonlthat exists but does not parse isisError: true. -
Step 1: write the failing tests: temp root with session
2026-09-05-1200holdingsession.json,raw.jsonl(two lines,start_ms0 and 5000) andtranscript.md:sessions→ one summary withid == "2026-09-05-1200",transcribed == true;transcriptwithsince: 1→ one line withstart_ms == 5000,next == 2,state == "done";transcriptwithsince: 2→ no lines,next == 2;transcriptwithsince: 9→ no lines,next == 2; the append-order race:raw.jsonlholding aroomline at 5000 then acallline at 0,since: 1→ thecallline at 0 is returned (a time cursor would have lost it);transcriptwith{"session":"2026-09-05-1200","since":"1"}→error.code == -32602;transcriptwithsession: "../x",session: "",session: "."→isError: trueeach;transcriptwithsession: "nope"→isError: trueand text containingno session nope; a symlinklink -> 2026-09-05-1200in the root,session: "link"→isError: true, andsessionsdoes not listlink; a symlinkoutsidepointing at a temp directory outside the root holding asession.json→sessionsdoes not list it either; a real dirleakwhoseraw.jsonlis a symlink to a file outside the root →transcriptonleakisisError: truewith text containingrefusing symlink, andsessionslistsleakwitherrorset; a second dir withsession.jsonand a freshaudio/room.native.wav(written in the test so its mtime is now) and noraw.jsonl→state == "live",lines == []; a third dir withsession.jsononly →state == "pending",lines == []. -
Step 2: run
cargo test mcp::, expect FAIL on the new assertions (not implemented). -
Step 3: implement, validating each argument’s JSON type before touching the disk.
-
Step 4: run
scripts/check, expectCHECK OK. -
Step 5: commit:
git add src/mcp.rs src/session.rs && git commit -m "mcp: sessions and transcript tools"(omitsrc/session.rsif unchanged).
Task 3: Registering it, documented
Files:
- Create:
docs/using/mcp.md(frontmattertitle: "Reading sessions from an assistant",sidebar.order: 16) - Modify:
docs/using/commands.md(new## mcpsection in the existing per-verb style),docs/using/index.md(one line linking the new page in whatever list of pages it keeps) - Modify:
README.md(one sentence in the feature list, if it has one, pointing atdocs/using/mcp.md)
Interfaces:
-
Consumes: the running verb from Tasks 1 and 2.
-
Step 1: write
docs/using/mcp.md: what it is (three tools, read-only, stdio, Ambient supplies the ears and nothing else), the registration command for Claude Code (claude mcp add ambient -- "$(which ambient)" mcp, and the same path for the.appbundle’sContents/MacOS/ambient), a.mcp.jsonexample for other clients ({"mcpServers":{"ambient":{"command":"/path/to/ambient","args":["mcp"]}}}), one worked example per tool with the exact JSON the tool returns, the polling idiom (calltranscriptwith the previous reply’snextassince;statesays whether more will come), and the consent sentence from ADR 0016. Link the ADR. -
Step 2: verify every command in the page by running it: pipe a
tools/listrequest intocargo run -- mcpand paste the real output into the page rather than a typed one. -
Step 3: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjsand quote its last line (… 0 broken link(s)). -
Step 4: commit:
git add docs/using/mcp.md docs/using/commands.md docs/using/index.md README.md && git commit -m "docs: reading sessions over MCP"(omit files that did not change).