MCP Server
lunaris-mcp exposes Lunaris memory to any Model Context Protocol
(MCP) agent — Claude Code, OpenAI Codex, and anything else that speaks MCP —
over the stdio transport. The agent gets persistent, scope-isolated
memory it can write to and recall from across sessions, with the same
bi-temporal storage and atomicity guarantees as the rest of Lunaris.
MCP ≠ MemoryProtocol 0.1. This page is about the agent-facing MCP server (a stdio JSON-RPC tool surface). The MemoryProtocol chapter is a separate HTTP/SSE wire protocol for the Lunaris HTTP server. They solve different problems and are not interchangeable.
Install
No Rust toolchain is required for the npx / uvx paths — both download a
prebuilt binary for your platform on first run.
# Rust (builds from source → ~/.cargo/bin/lunaris-mcp).
# NOT `cargo install lunaris-mcp` — the crate is publish = false (it links
# lunaris-memory-service, which has a vendor/ path dep) so it is not on
# crates.io. Needs cmake + a C++ compiler for llama.cpp.
cargo install --git https://github.com/pilotspace/lunaris lunaris-mcp
# Node (no Rust toolchain)
npx -y @pilotspace/lunaris-mcp --help
# Python (no Rust toolchain)
uvx lunaris-mcp --help
Supported prebuilt platforms: linux-x64, linux-arm64, darwin-x64,
darwin-arm64, win32-x64. On any other platform, build from source with
the cargo install --git form above — plain cargo install lunaris-mcp
always fails (the crate is not on crates.io).
Registry availability. The
npx/uvxpackages (@pilotspace/lunaris-mcp,lunaris-mcp) are published as part of the npx/uvx distribution wave; until your registry shows them, the always-available paths are the prebuiltlunaris-mcp-<target>.tar.gzbinaries attached to each GitHub release (since v0.6.1) or thecargo install --gitsource build. The npm/PyPI wrappers honourLUNARIS_MCP_BIN_PATHfor air-gapped hosts.
Tool surface
20 tools are registered (all implemented) — nine durable-memory tools, four working-memory (scratchpad) tools, five curation tools, and two retention tools:
| Tool | Input | Returns |
|---|---|---|
memory.ingest | source, content, optional t_ref, metadata | { lsn } |
memory.recall | query, optional k, filters, as_of | { hits[] } |
memory.forget | target.source_prefix XOR target.episode_id, optional dry_run (defaults to true) | { status, dry_run, matched, removed } |
memory.list_scopes | (none) | { scopes[] } |
memory.remember | kind (decision|fix|preference|constraint), content, optional why, tags, dedupe_key | { lsn, was_duplicate, source } |
memory.record_decision | decision, rationale, optional alternatives, tags, dedupe_key | { lsn, was_duplicate } |
memory.record_edit | path, after, optional before, intent, dedupe_key | { lsn, was_duplicate } |
memory.status | (none) | backend capability profile + MQ queue-depth probes for the three worker topics and the never-drained __lunaris_audit__ |
memory.feedback | memory_id, sentiment (±), reason (required), optional dedupe_key | { lsn, was_duplicate, activation_applied } |
memory.verify_agenda | optional limit | { count, items[] } — episodes the staleness sweep flagged (recorded git anchor no longer matches HEAD for files they reference) |
memory.resolve | episode_id, action (keep | invalidate), optional reason, superseded_by | { status, episode_id, invalidated, agenda_removed } |
memory.dream_agenda | optional limit, min_cluster_size, max_activation | { status, total_candidates, count, clusters[] } — read-only; writes nothing |
memory.distill | kind (decision | lesson | invariant | gotcha), content, source_episode_ids, optional title, tags, dedupe_key | { status, distilled_episode_id, lsn, archived_count, was_duplicate } |
memory.profile | optional limit_per_section | { markdown, counts, total } |
memory.scratchpad_write | key, value, optional namespace | { lsn } |
memory.scratchpad_read | key, optional namespace | { found, value } |
memory.scratchpad_grep | pattern, optional namespace | { entries[] } |
memory.scratchpad_consolidate | optional namespace | { status, promotions, archives } |
memory.retention | optional max_age_ms (omit to READ), hard | { status, configured, max_age_ms, hard } |
memory.retention_enforce | optional dry_run (defaults to true) | { status, dry_run, configured, max_age_ms, hard, cutoff_ms, matched, removed } |
memory.ingest is the general capture path. memory.record_decision and
memory.record_edit are structured aliases that write intent-typed episodes
(source = "decision:<scope>" / "edit:<scope>") with optional dedupe_key
idempotency. memory.status reports the bound scope and backend capabilities
(queue_native, graph_native, rerank_native, native_rrf,
max_vector_dim, cypher_dialect, …).
The four curation tools are what separate Lunaris from a vector store with
an MCP wrapper — they let an agent maintain its memory, not just append to
it. memory.verify_agenda surfaces memories the background staleness sweep
believes have gone out of date (the git anchor recorded with the episode no
longer matches HEAD for the files it references); memory.resolve acts on one
— keep prunes the agenda row and leaves the episode live, invalidate
soft-deletes it via an MVCC tombstone so it stops appearing in
memory.recall. memory.dream_agenda is the read-only planner for
distillation: it surfaces clusters of ripe (referenced, not-yet-distilled) raw
episodes with activation stats and writes nothing, and memory.distill is
the transactional apply step that writes the distilled prose back as a durable,
highest-priority episode (source = "distilled:{kind}:<scope>",
source_priority = 95). memory.feedback records explicit ± human/agent
feedback on one memory with a required reason, writing a strong reinforcement
signal to the activation ledger that moves its recall ranking.
The roster is pinned against the real binary by
crates/lunaris-mcp/tests/server_boot.rs::server_boots_and_lists_all_tools,
which spawns the process and drives initialize → tools/list.
The four memory.scratchpad_* tools are working memory — transient,
key-addressed notes (drafts, plans, in-progress state) under a scratchpad/
namespace, separate from the durable episode log. scratchpad_write/read
are key-value put/get, scratchpad_grep lists entries by key-prefix, and
scratchpad_consolidate drains the scratchpad queue and promotes/archives
notes by activation. scratchpad_consolidate needs a native-queue backend;
Moon has one, so on 0.7.0 it is always available. (It still returns
{ status: "unsupported_backend" } if the connected substrate reports no
queue — see Storage.)
memory.forget previews by default: with dry_run omitted it scans,
returns { status: "preview", matched: N, removed: 0 }, and writes nothing.
Deleting takes an explicit "dry_run": false. This inverts the HTTP
POST /v1/forget default (dry_run: false there, for API compatibility) on
purpose — the MCP caller is a language model, so the irreversible branch must
be the one it has to ask for.
The wire DTOs are identical across MCP clients, and every request DTO carries
#[serde(deny_unknown_fields)] — no wire field can override the bound scope.
Progressive disclosure — the retrieval ladder
The server instructions (returned at MCP initialize) and every tool
description teach connecting agents to retrieve cheapest-first:
scratchpad_read/scratchpad_grep— exact or prefix key lookup; returns full verbatim values; no model load. Always first for known keys.memory.recallwith the defaultk = 5— hybrid semantic + BM25 preview pass. Hits are 200-character snippets (withepisode_id,source,score), not full episode text, and the first call in a process stages/loads the GGUF embedder.- Widen only on a miss — raise
k, addfilters.source_prefix(decision:,edit:,claude-code:), or passas_offor a bi-temporal point-in-time view.
There is intentionally no fetch-full-episode tool: widen k for more
context, or keep full-fidelity values in the scratchpad where reads are
verbatim. memory.status / memory.list_scopes are diagnostics, not
retrieval.
Scope is bound at startup
lunaris-mcp resolves one scope when it starts and never changes it from wire
payloads. Resolution order:
--scopeflag /LUNARIS_MCP_SCOPEenv var (highest priority).git remote.origin.url+ current branch → blake3 →"git_<hex16>".- Canonical cwd → blake3 →
"cwd_<hex16>".
The resolved scope is persisted to ~/.lunaris/scopes.json. To rename it,
edit the name field there and restart the host agent (which restarts the
lunaris-mcp child). See Multi-Agent & Scope for
the scope model.
Storage
Moon is the only backend, and the server resolves it in exactly this order:
| # | Source | Notes |
|---|---|---|
| 1 | --storage / LUNARIS_MCP_STORAGE | Explicit always wins. |
| 2 | ~/.lunaris/contextd-moon.url | The store a running lunaris-contextd advertises, adopted only after a loopback + RESP PING liveness probe (25 ms, LUNARIS_MOON_DISCOVERY_TIMEOUT_MS). |
| 3 | — | Refuses to boot, printing the quickstart. |
Step 2 is why an MCP server and the lunaris-hook daemon on the same machine
land in the same Moon without being configured twice — lunaris-hook has
always resolved this way, and lunaris-mcp now does too. An
advertised and probed store is not a guessed default: a stale file (contextd
crashed, its port recycled) fails the probe and is declined, which lands you in
step 3 rather than in somebody else’s Moon. The file is read once, at boot —
start contextd first, then the agent.
There is still no default. Through 0.6.x an unset value opened a per-scope
SQLite file at ~/.lunaris/<scope>.db; 0.7.0 deleted that backend, and nothing
guesses a store in its place — not SQLite, and not a hardcoded
moon://127.0.0.1:6380 either. A stdio server surfaces tool errors to its
client but not startup logs, so “starts, then fails every call” (or worse,
“starts, and quietly writes into an unrelated Moon”) was the worst outcome
available.
docker run -d --name lunaris-moon -p 6381:6379 \
ghcr.io/pilotspace/moon:0.8.5 \
--shards 1 --protected-mode no --appendonly yes
"env": { "LUNARIS_MCP_STORAGE": "moon://127.0.0.1:6381" }
--shards 1 is mandatory — an ingest is one MULTI/EXEC transaction and a
sharded Moon rejects it. All 20 tools work against Moon: native HNSW
vector search, BM25 keyword fusion, graph, queues, and search-side bi-temporal
reads. See
Running an external Moon.
Auto-launched Moon (opt-in build, development only). A source build with
cargo build -p lunaris-mcp --features embedded-moonmakeslunaris-mcplaunch an in-process Moon (rooted at./.lunaris-moon) when noLUNARIS_MCP_STORAGEoverride is set, then use it automatically. The feature is off by default and is not compiled into the publishednpx/uvx/cargo installbinaries. An explicit--storage/LUNARIS_MCP_STORAGEstill wins; a failed bring-up is now terminal (it used to fall back to SQLite — there is nothing to fall back to).
The first
memory.recallstages the GGUF embedder (~150 MB) and reranker to~/.lunaris/models/— expect ~30 s on a cold start, fast thereafter. SetLUNARIS_MCP_SKIP_STAGE=1if models are pre-staged.
Key environment variables
| Variable | Default | Description |
|---|---|---|
LUNARIS_MCP_SCOPE | derived from git/cwd | Force a specific scope name |
LUNARIS_MCP_STORAGE | (no default) | Storage URL. moon://host:port only. Unset: the server falls back to a live lunaris-contextd store advertised in ~/.lunaris/contextd-moon.url, else refuses to boot |
LUNARIS_MOON_DISCOVERY_TIMEOUT_MS | 25 | Liveness-probe budget for that discovery file (0 disables discovery) |
LUNARIS_GRAPH_ENABLED | off | Enable the graph extraction/write path (Moon graph recall) |
LUNARIS_MCP_LOG | info,rmcp=warn | tracing-style filter directive (logs to stderr only) |
LUNARIS_MCP_SKIP_STAGE | unset | Set to 1 to skip GGUF staging on first recall |
LUNARIS_MCP_BIN_PATH | unset | (npx/uvx wrappers) point at a pre-staged binary for air-gapped hosts |
stdout is the JSON-RPC transport.
lunaris-mcpwrites logs to stderr only. Anything printed to stdout (e.g. anechoin your shell profile) corrupts the MCP framing and silently disconnects the host agent.
Per-agent guides
- Claude Code —
claude mcp add, project-scoped.mcp.json, and the optional lifecycle hooks + context injection. - Codex CLI —
~/.codex/config.toml, plus hooks and thelunaris-contextdwarm sidecar.
The exhaustive guides — full hook tables, lunaris-contextd internals, and
measured timings — live in the repo:
docs/integration/claude-code.md
and
docs/integration/codex.md.
Status
The stdio transport is the supported path (Wave A). SSE transport with
Bearer auth and multi-user server mode are deferred to a later OIDC
milestone; running MCP as a feature flag on lunaris-server was evaluated and
rejected — see the
decision record.