Metadata-Version: 2.4
Name: aleth-mcp
Version: 0.3.1
Summary: MCP face for the memory engine: the memory verbs as MCP tools over the thin wire client. Any MCP-speaking agent gets persistent memory with one config entry.
Author: Aleth Authors
License: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aleth-client<2,>=1.1
Requires-Dist: mcp<2,>=1.0
Provides-Extra: door
Requires-Dist: cryptography<47,>=42; extra == "door"
Requires-Dist: starlette<1,>=0.27; extra == "door"
Requires-Dist: uvicorn<1,>=0.31.1; extra == "door"
Dynamic: license-file

# aleth-mcp

The MCP face for the memory engine: the ten memory verbs, `remember`,
`recall`, `show`, `search`, `retract`, `forget`, `supersede`, `connect`,
`status`, `nodes`: exposed as MCP tools, as a thin shell over the
`aleth-client` wire core.

Three of those correct a belief and they are NOT interchangeable:
`retract` says it was wrong, `supersede` says it was true and something
newer replaced it, `forget` destroys it.


Any MCP-speaking agent gets persistent, associative, erasable memory with
one config entry:

```json
{
  "mcpServers": {
    "memory": { "command": "aleth-mcp" }
  }
}
```

## What it is (and is not)

- **Thin.** Every tool is one client call; results are the frozen wire
  models as plain dicts. No state of its own, no extra verbs.
- **Lazy.** The server starts and lists tools even before the engine is up;
  the first call connects. Connection problems come back in-band
  (`{"error": ...}`), so the agent sees them instead of a dead server.
- **Engine-agnostic on the wire.** It speaks the frozen memory wire, so it
  works against the standalone memory server and the full daemon alike --
  set `MEMORY_DISCOVERY_DIR` if the engine's discovery file lives somewhere
  non-default.
- **`forget` really forgets.** Permanent erasure (graph + storage + vacuum),
  distinct from `retract` (kept but flagged). That split is the product's
  thesis, not a convenience.

## Install / run

```bash
pip install aleth-mcp
aleth-mcp          # stdio MCP server (bare invocation -- how hosts launch it)
```

## Register with an AI host

The `install` subcommand writes the server entry into a host's own config file
(merge-only, backed up, idempotent). The bare `aleth-mcp` invocation is
unchanged, only the `install` / `uninstall` / `hosts` subcommands trigger the
writer.

**Start the engine first, and run `install` from the folder you started it
in.** The engine keeps its memory under the folder `aleth start` runs in, and
a host launches this server from a folder of its own. So `install` finds the
running engine from where you are and writes its location into the host's
entry (`ALETH_DISCOVERY_DIR`); every host you install that way shares the one
engine. Started it with `--data-dir`? Pass the same `--data-dir` to `install`.

```bash
aleth-mcp hosts                 # list hosts + which are detected here
aleth-mcp install claude-code   # add "memory" to ./.mcp.json
aleth-mcp install --all         # install into every detected host
aleth-mcp install cursor --project --instructions   # config + rules file
aleth-mcp install codex --dry-run                   # show the diff, write nothing
aleth-mcp uninstall claude-code                     # remove exactly our entry
```

Flags: `--all` (detected hosts only), `--project` / `--user` (scope, where the
host supports both), `--dry-run` (unified diff, no write), `--instructions`
(also inject the memory instruction block), `--hooks` (also wire automatic
capture, see below).

Behaviour: existing config is parsed and only our `memory` entry is
added/replaced (unknown keys preserved); a timestamped `.bak` is written next
to the file before every change; re-running with identical content writes
nothing. JSON hosts are reserialized (2-space indent; all other keys survive).
TOML and comment-bearing files are edited by surgical text splice. If a
JSONC-capable file (VS Code, Zed) actually contains comments, the writer
**fails soft**: it refuses to touch the file and prints the exact snippet to
paste, rather than dropping your comments.

### What gets written where

| Host | Config file (default scope) | Container key | Source |
|------|-----------------------------|---------------|--------|
| `claude-code` | `./.mcp.json` (project) or `~/.claude.json` (`--user`) | `mcpServers` | own-knowledge |
| `claude-desktop` | `%APPDATA%\Claude\claude_desktop_config.json` / `~/Library/Application Support/Claude/...` / `~/.config/Claude/...` | `mcpServers` | own-knowledge |
| `cursor` | `~/.cursor/mcp.json` (global) or `./.cursor/mcp.json` (`--project`) | `mcpServers` | own-knowledge |
| `codex` | `~/.codex/config.toml` | `[mcp_servers.memory]` (TOML) | [OpenAI docs](https://developers.openai.com/codex/config-reference) |
| `vscode` | `./.vscode/mcp.json` | `servers` (note: not `mcpServers`; stdio `type` required) | [VS Code docs](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) |
| `gemini-cli` | `~/.gemini/settings.json` | `mcpServers` | own-knowledge |
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` | own-knowledge |
| `zed` | `~/.config/zed/settings.json` / `%APPDATA%\Zed\settings.json` | `context_servers` (`{command:{...}}` shape) | [Zed docs](https://zed.dev/docs/ai/mcp) |
| `cline` | `<VS Code User>/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | `mcpServers` | own-knowledge + [cline/cline](https://github.com/cline/cline) |

Instruction-line injection (`--instructions`) writes a short, marker-fenced
memory usage block into the host's instruction file where one exists:
`CLAUDE.md` (claude-code), `AGENTS.md` (codex), `.cursor/rules/memory.mdc`
(cursor), `GEMINI.md` (gemini-cli).

## Automatic capture

An agent that has to remember to call `remember` will sometimes not, and what
it did not store is gone. Capture closes that gap at the host, not in the
engine: the host runs a command at each lifecycle event, and four of those
events are wired to `aleth-mcp capture`.

| Lifecycle moment | Command | What it does |
|-------------------|---------|--------------|
| the user sends a prompt | `aleth-mcp capture prompt` | spools what the user said, verbatim |
| a tool finishes | `aleth-mcp capture tool` | spools one line per action: the tool and the argument that names it, never the tool's output |
| the turn ends | `aleth-mcp capture stop` | stores the user's words (origin `user`) and the agent's record (its final message plus the action list, origin `agent`), links the two, and links the turn to the previous one |
| the session ends | `aleth-mcp capture session-end` | a bounded last attempt for anything still spooled |

### Wiring it into a host

`aleth-mcp capture hook` is the same thing behind one name. It reads the event
out of the payload and routes it, so a host config names one command four times
instead of four different ones. `--hooks` writes that wiring for you:

```bash
aleth-mcp install claude-code --hooks    # ./.claude/settings.json
aleth-mcp install codex --hooks          # ~/.codex/hooks.json
aleth-mcp install gemini-cli --hooks     # ~/.gemini/settings.json
aleth-mcp install cursor --hooks         # ./.cursor/hooks.json
aleth-mcp install --all --hooks          # every host detected here
aleth-mcp uninstall cursor --hooks       # remove exactly our hooks
```

Same discipline as the server entry: merge only, timestamped `.bak`, idempotent,
your other hooks and settings untouched, and a file with comments in it is left
alone with the snippet printed for you to paste. `--project` / `--user` pick the
scope; the default is the one that makes sense per host, so Cursor's hooks go in
the repo while Codex's go in your home directory. Ship a project's `.cursor/` or
`.codex/` hooks file in version control and everyone working in that repo gets
capture.

The Claude Code plugin (`packaging/claude-code-plugin/`) and the Gemini CLI
extension (`packaging/gemini-cli-extension/`) already carry their hooks, so
installing either of those needs no `--hooks` at all.

What the record is, and is not:

- **It is the record.** Nothing is summarised or rewritten by a model. The
  user's words are stored as said; the agent's record is its own final message
  and the list of what it ran. Recall gets what happened, not a reading of it.
- **It is selective only where selection is mechanical.** A go-signal ("ok",
  "continue"), a slash command or a host-injected payload is not stored as the
  user's words; the actions it triggered still are. The agent's own `memory_*`
  calls are not recorded as actions, because they are writes already. Long
  texts are clipped, never dropped.
- **It never costs a turn.** Every path exits 0 and reports a failure as one
  line on stderr. With the engine down, the turn waits in a local spool and is
  stored at the next `stop`, of this session or any other; a window that dies
  mid-turn leaves a spool that the next `stop` anywhere sweeps up.
- **Off is one variable.** `ALETH_CAPTURE=0` in the shell the host runs in.
  The spool lives under `ALETH_CAPTURE_DIR`, else Claude Code's per-plugin data
  directory, else `~/.aleth/capture`. The engine's own settings are untouched.
- **Forgetting still works.** Something said in passing is in the store;
  `memory_forget` erases it for real, as it always did.
- **Nothing goes to stdout.** Some hosts parse a hook's stdout, so every word
  the capture has to say goes to stderr and every path exits 0. A hook never
  costs you a turn.

The distilled fact, the correction (`supersede` / `retract`) and the erasure
stay the agent's job; the plugin's skill and the extension's context file say
so at every session start.

Claude Code's payload was measured on a real run. Codex, Gemini CLI and Cursor
are wired from their published references and have not been observed on a live
install; if one of them renames a field the record gets shorter, never broken.

## The remote door (optional)

Everything above is stdio: a pipe, on your machine, started by a host you
installed. A web app that takes a *remote* MCP connector (ChatGPT
Developer Mode, Claude.ai connectors) cannot speak down a pipe, so
there is a second mode.

```powershell
pip install "aleth-mcp[door]"
aleth-mcp serve-http --public-url https://<your tunnel hostname>
```

MCP over HTTPS with OAuth 2.1: authorization code with PKCE for browsers,
device code for terminals, dynamic client registration so a connector
registers itself. **Read-only by default**; write is a separate button on
a consent page served from your own machine, and the scope is enforced at
the tool boundary: a read-scoped token calling `memory_remember` gets an
MCP error naming the missing scope.

The door is yours, and so is the key. The authorization server is this
same process, the certificate is generated on your disk on first run, and
the tunnel is one you run (Cloudflare Tunnel, Tailscale, ngrok, your own
reverse proxy). We operate nothing in the path and hold nothing, which is
why "we can't" survives the move to the web.

It is **off until you type the command.** No config setting, no
environment variable and no hook reaches it; `aleth-mcp install` writes
only the stdio entry, and the stdio path is unchanged by any of this.
Stop the command and every token it issued is gone.

Recipes for each tunnel, the scope-per-verb table, the flags, and a plain
list of what this door does **not** protect against: **[DOOR.md](DOOR.md)**.

## Testing

`tests/test_install.py` drives the host-config writers against real temp
dirs (HOME / APPDATA redirected to `tmp_path`, no mocked file IO, no test
touches a real config dir). The handler layer is exercised end-to-end
against a real in-process engine + RPC server + wire client, no mocks,
the stdio transport the only layer not under test.

`tests/test_door.py` starts the remote door on loopback with a generated
certificate and drives it over a real socket: the metadata documents,
dynamic client registration, the PKCE code flow and the device flow end
to end, an expired token, a token bound to another resource, the rate
limiter driven past its limit, and a real MCP client proving a
read-scoped token cannot write. Four of its tests exist to go RED when a
guard is deleted (PKCE verification, expiry, audience and the scope
check), and each one was deleted once and watched to fail.

```bash
python -m pytest tests/ -q
```
