mcp — ctxlake mcp
ctxlake mcp is a stdio MCP server: JSON-RPC 2.0 over newline-delimited stdin/stdout, speaking protocol revision 2024-11-05. It is the one integration surface that works identically on every runtime this project supports — Claude Code, Cursor, and Hermes are all MCP clients, so a runtime with no native hook surface for something can still get it through this server, and a runtime with hooks gets a second, symmetric way to ask for the same information mid-turn instead of only at fixed lifecycle points.
Status: the server is implemented;
ctxlake mcpis not wired up yet. The protocol layer and every tool — fleet and memory alike — work today against the local filesystem, and this crate ships a realctxlake-mcpbinary that runs them on real stdin/stdout. What does not exist yet isctxlake-cli'smcpsubcommand, so there is noctxlake mcpto spawn and noctxlake installto write the config block below into a runtime's settings. If you want to run this server today, point a runtime's MCP config at thectxlake-mcpbinary this crate builds directly (cargo build -p ctxlake-mcp --bin ctxlake-mcp), or callctxlake_mcp::run_stdio()from your own thin wrapper — both do exactly whatctxlake mcpwill do once it exists.
memory_search/memory_timeline(wave 4) now read the real local claim snapshot —<cache_root>/<fleet_id>/snapshot.bin,ctxlake sync's byte-for-byte mirror ofctxlake-maint::snapshot::publish's SQLite artifact — rather than a placeholder cache file with no writer."enabled": falsemeans exactly what it always did: noctxlake maintrun has ever published a snapshot for this fleet (the common case on a fresh install, or any install with[summarize] mode = "none"/"agent", the documented default). A fleet that has run extraction but is sitting in[summarize] mode = "shadow"(also a documented default while an operator builds trust in the extracted claims — see memory.md) instead reads"enabled": truewith an always-empty"results"/"entries"array: the snapshot exists and opens fine, every claim in it is simply marked not-agent-visible at the row level. Both are deliberate "reads nothing" outcomes, not bugs to chase down — memory.md has the full claim model they're built against.
Why this process never touches the object store
docs/architecture.md's component table draws the same box around ctxlake-mcp that it draws around ctxlake-hook, for the same reason: this process is on a path an agent is synchronously waiting on, so AGENTS.md invariant 1 — the store is never on the hook path, in either direction — applies here too. Concretely:
- Reads come from the local cache:
~/.ctxlake/cache/<fleet_id>/*.json, whateverctxlake sync's store→cache leg most recently wrote there. - Writes go to the local spool:
~/.ctxlake/spool/mcp/<fleet_id>.ndjson, one JSON line per write-shaped tool call, forctxlake syncto apply later. crates/ctxlake-mcp/Cargo.tomllinks onlyctxlake-core,serde, andserde_json— noobject_store, notokio. That is not merely a description of current behavior; it means the network boundary can't be crossed by accident. Reintroducing either dependency would be a visible, explainable diff, not a quiet regression.
One consequence worth being explicit about, because it's easy to expect otherwise: fleet_claim cannot tell you that you now hold a lease. Holding a lease is a fact about the object store's live/leases/ key (see coordination.md), and this process never reads or writes that key directly. What it can honestly say is "this request is queued for ctxlake sync to apply" — so that's exactly what every write-shaped tool's result says. Leases are advisory even when the full round trip happens (AGENTS.md invariant 5); a tool that can't even complete the round trip has to be more careful about its claims, not less.
The on-disk root is ~/.ctxlake/{spool,cache} (override with CTXLAKE_SPOOL_DIR / CTXLAKE_CACHE_DIR), resolved through ctxlake_core::paths — the single definition every crate shares. Cache reads are scoped per fleet (<cache_root>/<fleet_id>/), because one host can legitimately run agents in more than one fleet and an unscoped roster would have them overwrite each other's view of who is active.
Wiring it up
The eventual shape — once ctxlake-cli grows an mcp subcommand and an install command to go with it (see the status callout above) — is each runtime's own MCP config pointing at the ctxlake binary:
{
"mcpServers": {
"ctxlake": { "command": "ctxlake", "args": ["mcp"] }
}
}with ctxlake install merging that block into a runtime's config (see getting-started.md) the same way it merges the hook entries. Neither ctxlake mcp nor ctxlake install exists today, so that block is not yet something you can paste in and expect to work — point "command" at the ctxlake-mcp binary this crate builds instead (see the status callout for how to build it).
CTXLAKE_FLEET_ID and CTXLAKE_AGENT_ID scope every read and write this process does — set them in the runtime's MCP config's env block until ctxlake install can set them for you.
The tools
| Tool | Shape | What it does |
|---|---|---|
fleet_status() | read | Who's active and what they hold, as of the last cache refresh. |
fleet_claim(paths[], reason, ttl_secs?) | write | Queue an advisory-lease request. |
fleet_release(paths[]?) | write | Queue a release; omit paths for "everything I hold." |
fleet_history(repo?, since?, limit?) | read | Recent sessions and outcomes from the cache. |
fleet_handoff(summary, status, next?) | write | Leave a note for whoever picks this up next. |
memory_search(query, scope?, subject?, claim_type?, k?) | read | Promoted claims matching query (FTS5 lexical + brute-force cosine), with attribution. |
memory_propose(claim, type, subject, evidence[]) | write | File a claim candidate. Never promotes. |
memory_timeline(subject, since?, limit?) | read | What this fleet has actually tried, re: subject. |
Every read tool degrades honestly when its cache file is missing or unparseable: an empty result with a note explaining why, never an error and never a guess. Every write tool queues to the local spool and returns once the write is durably queued locally — never once it has reached the fleet, which this process cannot observe.
Row counts are capped, independently of field length
sanitize.rs's per-field length bound (below) stops one oversized field from crowding out a model's context window; it does nothing about row count. fleet_history, memory_search, and memory_timeline each cap how many rows a single call can return — limit/k default to 50 (10 for memory_search, unchanged) and are clamped to a hard ceiling of 200 no matter what a caller asks for. A result that had more matching rows than it returned says so with "truncated": true, rather than silently dropping the tail the way the length bound's truncation marker makes visible for a single field. memory_propose's evidence array gets the same treatment on the write side: at most 20 citations per call, rejected outright over that rather than silently trimmed.
memory_propose never writes a promoted claim
This is AGENTS.md invariant 9, enforced structurally, not by convention: there is no memory_write tool in tools/list, propose() spools a ClaimEvent::Proposed whose scope is hard-coded "agent" with no argument on the function's signature that could widen it, and nothing in crates/ctxlake-mcp/ ever constructs a claims/fleet/* write — that key is written only by the promotion gate inside ctxlake maint, a different binary this crate does not even depend on. A conformance test asserts the tool list stays free of memory_write; other tests assert a proposed claim's spooled record is a proposed event and never contains the word promoted, and that its scope is never anything but "agent".
propose also enforces memory.md's "no evidence, no claim" rule itself, before anything is queued, at the level of a single citation: an empty evidence array is refused outright, and so is any citation missing a non-empty session_id or message_id — [{}] used to pass the old "array is non-empty" check even though it cites nothing at all.
The record propose spools is byte-for-byte the same shape ctxlake-maint::claims::ClaimEvent::Proposed serializes to (see crates/ctxlake-mcp/src/wire.rs) — a deliberate second definition of the same wire format, not a shortcut, since this crate cannot depend on ctxlake-maint without pulling in object_store/tokio. A future daemon-side drain of spool/mcp/*.ndjson into real claims/events/*.json objects (not yet built anywhere in this codebase) can therefore append this record with zero translation.
memory_search's attribution shape
memory_search reads <cache_root>/<fleet_id>/snapshot.bin (see the status note above) and combines FTS5 lexical matching with brute-force cosine over each claim's 256-dim embedding — crates/ctxlake-mcp/src/snapshot.rs has the full contract, including the honest limitation that no real embedder exists anywhere in this codebase yet, so the "embedding" today is a deterministic, dependency-free lexical hash rather than anything semantic. A hit renders exactly like memory.md specifies, never as bare fact:
[cc-03, 2026-09-09, 2 independent sessions, conf 0.81]
`cargo test --workspace` needs RUSTFLAGS=-D warnings or the clippy gate fails later.A contested claim says so inline (, CONTESTED), and a claim resting on a single session reads as "1 independent session," not silently rounded up to sound more corroborated than it is. independent_count is the only session count ever rendered — there is no evidence_count field on the type render() accepts, so a raw evidence tally (which can overcount correlated, non-independent corroboration) can never leak into a rendered result by accident.
Every field read from the lake is treated as an attack surface
A claim, a roster entry, a handoff note — all of it was written by another agent's session, mirrored into the local cache by a daemon this process trusts to move bytes faithfully but not to have sanitized them. Before any such field reaches a tool result:
- Zero-width and bidi control codepoints are stripped — zero-width joiners/non-joiners/space, the BOM, the word joiner, and bidi embedding/override/ isolate controls. All of them can make text read differently than it renders, which is exactly the property an injected instruction hiding inside a claim would want.
- Every field is length-bounded, with a visible
…[truncated]marker rather than a silent cut, so an oversized field can't quietly dominate a rendered result.
This happens in crates/ctxlake-mcp/src/sanitize.rs, at render time, on every field this crate ever hands back — not only once, at ingest, on the theory that whatever wrote the cache already cleaned it. Concretely, sanitize::clean_value recurses over the whole JSON value a cache file handed back — object keys and values, array elements, at any depth — rather than naming a fixed list of fields to clean. This crate does not control the schema the future cache-writer (ctxlake sync, Wave 3) will actually use, so a fixed field list would only cover whatever shape today's author guessed at; a field the list didn't name, or one nested inside a field it did, would pass through untouched. Recursing over the value has no such gap: whatever shape a cache record takes, every string in it is cleaned before this crate hands it back.
It deliberately does not try to pattern-match phrases like "ignore previous instructions"; that's a losing game, and it isn't the actual defense here. The actual defense is architectural: memory.md's attribution framing means a peer's claim never arrives as a bare assertion the reading model might follow — it arrives labeled as somebody else's observation, with a session count and confidence attached, under a "verify before relying on these" heading. Sanitization's job is only to make sure that framing can't be visually hidden or defeated by an invisible character.
Every write-shaped argument is scrubbed for secrets before the spool
The read-time cleaning above closes an injection channel; it says nothing about a different direction of harm, AGENTS.md invariant 7: a secret an agent pastes into a fleet_claim reason, a fleet_handoff summary, or a memory_propose claim or evidence citation must never reach spool.rs's ndjson file, because ctxlake sync ships that file's contents into bronze, and bronze is immutable — nothing after this point can un-leak it. crates/ctxlake-mcp/src/write_guard.rs runs every free-text argument to fleet_claim, fleet_release, fleet_handoff, and memory_propose through ctxlake_core::redact::Redactor — the same scrubber ctxlake-hook's adapters use for the identical reason — before the record is ever built, so a literal secret marker (sk-, AKIA, a PEM header, ...) is withheld rather than written verbatim. evidence is caller-shaped JSON, not a fixed set of named fields, so it gets the same recursive treatment as the read path: every string at any depth is scrubbed, not only the fields this crate happens to know about.
Nothing drains spool/mcp/*.ndjson yet either (that daemon-side consumer is later work, same as ctxlake-hook's own spool), and every agent in a fleet shares one file per fleet. spool.rs caps that directory's total size and rotates a fleet's file once it gets large, mirroring ctxlake-hook's own spool guard — but where the hook must silently drop an over-cap event (it can never fail the host agent's turn), this server has a real return channel: hitting the cap comes back as an ordinary tool-call error the calling agent can see and act on.
The briefing's fleet-context block
ctxlake-cli's SessionStart briefing (crates/ctxlake-cli/src/briefing.rs) has a third block alongside live agents and recent sessions: promoted claims, via ctxlake_mcp::memory::briefing_claims. It reuses this crate's own snapshot read and attribution renderer rather than a second implementation — same sanitizer, same attribution shape, same structural shadow-mode emptiness described above — capped small (five lines by default) since a briefing rides in on every session's context window, where memory_search is the tool to reach for anything more. This is a documented divergence from docs/architecture.md's diagram, which describes ctxlake maint pre-rendering a briefing blob server-side instead; see briefing.rs's own module doc for why this wave took the client-side rendering path that was actually buildable within its scope.
Protocol conformance
initialize, tools/list, and tools/call are the whole method surface (plus ping, answered but not required). Every other input path returns a JSON-RPC error rather than a panic or a dropped connection:
| Input | Response |
|---|---|
| Malformed JSON | -32700 Parse error, id: null |
| A JSON value that isn't an object | -32600 Invalid Request |
An object with no method | -32600 Invalid Request (if it has an id) or dropped (if not — a notification) |
| An unknown method | -32601 Method not found |
tools/call for an unknown tool name | -32602 Invalid params |
tools/call with a missing/wrong-typed required argument | -32602 Invalid params |
| A tool called correctly but refusing on its own terms (e.g. no evidence) | a normal result with isError: true — not a protocol error, so the session keeps going |
A notification (id absent) | never answered, even for an unknown method — the JSON-RPC 2.0 rule |
A stray result/error frame with no method | never answered — it isn't addressed to this server |
stdout carries JSON-RPC frames and nothing else. Every diagnostic in this crate goes to stderr. A client parses stdout one line at a time as JSON; a stray println! would corrupt every frame after it in a way that is hard to diagnose from the client's side. Two tests check this from two different vantage points, because they catch different bugs: lib.rs's in-memory test drives a full mixed session (well-formed calls, garbage, notifications, bad arguments) through serve() against a buffer it controls, which proves the response-building logic never emits more or less than one frame per request — but it cannot see a stray write to the process's real stdout, since nothing in that test path touches it. tests/stdio_subprocess.rs closes that gap by spawning the actual ctxlake-mcp binary as a child process and asserting every line on its real stdout is exactly one valid JSON-RPC frame; a println!/eprintln!-to-stdout mistake anywhere on the dispatch path fails this test and only this test.
Next steps
- memory.md — the claim model
memory_search/memory_proposeare built against, and the belief layer they're waiting on - coordination.md — what an advisory lease actually promises, and what it does not
- architecture.md — this process's place in the full component map