---
title: "Plan: reading sessions over MCP"
---

> Documentation Index
> Fetch the complete documentation index at: https://jasonm4130-labs.github.io/ambient/llms.txt
> Use this file to discover all available pages before exploring further.

# Plan: reading sessions over MCP

# Plan: reading sessions over MCP

**Goal:** let another program read Ambient's sessions, including the one being
recorded, through `ambient mcp`, per [ADR 0016](../adr/0016-mcp-verb-for-live-reading.md).
**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

Source: https://jasonm4130-labs.github.io/ambient/plans/2026-09-05-mcp/index.mdx
