Skip to content

Plan: reading sessions over MCP

Updated View as Markdown

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, \n terminated; nothing else on stdout. Diagnostics go to stderr. The loop never panics on input: every handler returns a Result and an Err becomes a JSON-RPC error response.
  • Protocol version: reply with the client’s protocolVersion when it is one of 2025-11-25, 2025-06-18, 2025-03-26; otherwise reply 2025-11-25, the latest this server supports, which is what the lifecycle spec requires for an unsupported request. The 2026-07-28 revision retired the initialize handshake and is not claimed.
  • Error classes: a line that is not JSON → -32700; an object without "jsonrpc":"2.0" or a non-string method → -32600; an unknown method → -32601; tools/call with missing or non-object params/arguments, a non-string name, 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 with isError: true and 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 ..), and root.join(id) must be a directory by symlink_metadata (a symlink is refused); otherwise the tool errors, and no path outside the root is ever read. Enumeration obeys the same rule: one private fn session_dirs(root) -> Vec<PathBuf> in src/mcp.rs walks the root, keeps entries whose symlink_metadata is a directory, and keeps those holding a session.json or a growing audio/room.native.wav; both sessions and status.sessions use it, so they always agree (session::list follows symlinks, which is why the walk lives here). The files inside a session are checked the same way: before raw.jsonl, edits.jsonl or session.json is read, its symlink_metadata must be a regular file, else the tool errors with refusing symlink <path> (for sessions, that session is listed with error set and metadata None).
  • This plan lands after the session-tools plan. Where a task consumes session::summaries, session::live_session_in or Line: Serialize, and they are not on main, the task adds them exactly as specified in its Interfaces before proceeding.
  • scripts/check does not look at docs. After any docs edit run node .github/scripts/check-links.mjs and quote its … 0 broken link(s) line.
  • No .github/workflows/, .claude/, or loop/ changes; no editing existing tests.

Task 1: The JSON-RPC loop with initialize, ping, tools/list and status

Files:

  • Create: src/mcp.rs; register pub mod mcp; in src/lib.rs before writing the test
  • Modify: src/main.rs (new mcp arm calling ambient::mcp::serve(std::io::stdin().lock(), std::io::stdout().lock(), &ambient::session::home())), USAGE

Interfaces:

  • Consumes: session::live_session_in(root) (add as pub fn live_session_in(root: &Path) -> Option<PathBuf>, the body of live_session() with root in place of home(), 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<()>, returning Ok(()) at EOF; pub fn handle(line: &str, root: &Path) -> Option<serde_json::Value> (None for notifications, which have no id); the tool status with no arguments returning {"live": {"id": "<dir name>"} | null, "awaiting_transcript": <n>, "sessions": <n>} where sessions is session_dirs(root).len(), the set the sessions tool lists; tools/list returning the three tool descriptors sessions, transcript, status with inputSchema (transcript’s schema: session string required, since integer, verbatim boolean; the other two {"type":"object","properties":{}}); until Task 2, tools/call on sessions or transcript returns isError: true with text not implemented.

  • Step 1: write the failing tests in src/mcp.rs’s test module, each feeding a &[u8] of request lines to serve with a Vec<u8> writer and a temp root, then parsing each output line:

    • initialize with {"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}} → result has protocolVersion == "2025-11-25", capabilities.tools is an object, serverInfo.name == "ambient", serverInfo.version == env!("CARGO_PKG_VERSION").
    • initialize with protocolVersion: "2026-07-28" → protocolVersion == "2025-11-25".
    • notifications/initialized (no id) → no output line.
    • ping → result == {} with the same id (a string id "a" round-trips as a string).
    • tools/list → three tools named sessions, transcript, status, each with inputSchema.type == "object".
    • tools/call with {"name":"status","arguments":{}} on a root holding one session dir with session.json and transcript.md, plus a directory notes without one, plus a symlink outside pointing at a temp directory outside the root that holds a session.json, plus a directory live holding only a freshly written audio/room.native.wav → text parses to {"live":{"id":"live"},"awaiting_transcript":0,"sessions":2}.
    • tools/call with an unknown name → error.code == -32602.
    • tools/call with no params → error.code == -32602; with {"name":5} → -32602.
    • {"id":1,"method":"ping"} (no jsonrpc) → -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 ping still answers (the loop survived).
  • Step 2: run cargo test mcp::, expect FAIL “cannot find function serve”.

  • Step 3: implement: read lines; skip blank ones; handle dispatches on method; responses are {"jsonrpc":"2.0","id":<id>,"result":…} or {"jsonrpc":"2.0","id":<id>,"error":{"code":…,"message":…}}. live in status comes from live_session_in(root).

  • Step 4: run scripts/check, expect CHECK OK. Then printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"ping"}' | cargo run -- mcp and 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" (omit src/session.rs if unchanged).

Task 2: The sessions and transcript tools

Files:

  • Modify: src/mcp.rs (implement the two tools)
  • Modify: src/session.rs (new pub fn transcript_appended(dir: &Path, verbatim: bool) -> Result<Vec<Line>> next to transcript at src/session.rs:1059; and summaries or Line: Serialize if 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 holds session.json or a growing audio/room.native.wav, since capture writes session.json only after the scratch wavs close; an unparsable session.json is included with error set); #[derive(Serialize)] on Line { 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 over raw.jsonl and edits.jsonl as transcript but returning lines in raw.jsonl order, without the sort_by_key(start_ms) at src/session.rs:1123; refactor so both share one body with a sort: bool argument in a private helper, so they cannot drift. Test in src/session.rs: raw.jsonl holding a room line at 5000 then a call line at 0 → transcript returns [0, 5000] by start_ms, transcript_appended returns [5000, 0].

  • Produces: tool sessions (no arguments) → the JSON array of summaries; tool transcript with {"session": "<id>", "since": <u64, optional, default 0>, "verbatim": <bool, optional, default false>} → {"session": "<id>", "state": "<live|transcribing|pending|done>", "next": <u64>, "lines": [<Line>…]}. since is a count of lines already seen in append order (the order transcript_appended returns), lines is every line from index since on, and next is the total line count, the value to pass back as since. The cursor is append order and not a timestamp because raw.jsonl is written room track first, then call track (src/session.rs:852), and the two tracks’ start_ms clocks are independent, so a time cursor would skip call lines appended after room lines. state is live while audio/room.native.wav is growing, transcribing while live_transcriber names a live pid, done when transcript.md exists, and pending otherwise (captured, waiting for the queue). When raw.jsonl is absent, lines is empty and the call succeeds; a raw.jsonl that exists but does not parse is isError: true.

  • Step 1: write the failing tests: temp root with session 2026-09-05-1200 holding session.json, raw.jsonl (two lines, start_ms 0 and 5000) and transcript.md: sessions → one summary with id == "2026-09-05-1200", transcribed == true; transcript with since: 1 → one line with start_ms == 5000, next == 2, state == "done"; transcript with since: 2 → no lines, next == 2; transcript with since: 9 → no lines, next == 2; the append-order race: raw.jsonl holding a room line at 5000 then a call line at 0, since: 1 → the call line at 0 is returned (a time cursor would have lost it); transcript with {"session":"2026-09-05-1200","since":"1"} → error.code == -32602; transcript with session: "../x", session: "", session: "." → isError: true each; transcript with session: "nope" → isError: true and text containing no session nope; a symlink link -> 2026-09-05-1200 in the root, session: "link" → isError: true, and sessions does not list link; a symlink outside pointing at a temp directory outside the root holding a session.json → sessions does not list it either; a real dir leak whose raw.jsonl is a symlink to a file outside the root → transcript on leak is isError: true with text containing refusing symlink, and sessions lists leak with error set; a second dir with session.json and a fresh audio/room.native.wav (written in the test so its mtime is now) and no raw.jsonl → state == "live", lines == []; a third dir with session.json only → 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, expect CHECK OK.

  • Step 5: commit: git add src/mcp.rs src/session.rs && git commit -m "mcp: sessions and transcript tools" (omit src/session.rs if unchanged).

Task 3: Registering it, documented

Files:

  • Create: docs/using/mcp.md (frontmatter title: "Reading sessions from an assistant", sidebar.order: 16)
  • Modify: docs/using/commands.md (new ## mcp section 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 at docs/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 .app bundle’s Contents/MacOS/ambient), a .mcp.json example 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 (call transcript with the previous reply’s next as since; state says 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/list request into cargo run -- mcp and paste the real output into the page rather than a typed one.

  • Step 3: run scripts/check, expect CHECK OK; run node .github/scripts/check-links.mjs and 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).

Open Questions

Navigation

Type to search…

↑↓ navigate↵ selectEsc close