Second Brain
by robbrad
README.md
# second-brain-mcp
An MCP server that lets many Claude sessions share one "LLM wiki" (an Obsidian-format
Markdown vault, in the style of Karpathy's LLM Wiki) without trusting any of them to edit it.
Sessions on your LAN or VPN can **search and read** the wiki and **contribute** new material
to an inbox. Only a separate curator, a locked-down headless Claude Code run each night, ever
edits the wiki itself.
```
Claude sessions --Bearer token--> second-brain-mcp container (:8765)
(laptop, desktop, server) | reads wiki/ scripts/ raw/ (mounted read-only)
| writes raw/inbox/ only (contribute)
v
vault folder (local disk or a NAS share)
^
nightly curator timer --------------+ headless Claude Code: raw/inbox/ -> wiki/
Obsidian on your PC ----------------+ opens the same folder
```
## Design rules
- **Single writer.** The server can't edit `wiki/`. Docker mounts it read-only, and the only
writable path is `raw/inbox/`. Curating is a separate step that follows the vault's
`CLAUDE.md` rulebook.
- **Contributions are data, not instructions.** Each one is a new file
`raw/inbox/YYYY-MM-DDTHHMMSS-<host>-<slug>.md` with frontmatter `type: contribution`,
`title`, `kind` (fact | decision | session-summary | snippet), `origin_host` (the token
label, which is authenticated), `origin_client` (the MCP client info, self-reported),
`origin_ip`, `submitted`, plus optional `related` and `source_url`. It's written to a temp
file and renamed into place. Bodies over 100 KB, unsafe titles and non-http(s) URLs are
refused. The curator defers anything that tries to instruct it or contains secrets.
- **No model in the server.** Search is BM25. `audit` and `/graph` run the vault's own
`scripts/audit_graph.py` and `scripts/build_graph.py`; the audit runs on a scratch copy,
so the vault is never written.
- **Secrets stay out of the vault.** Tokens live in `secrets/tokens` (directory mode 700, file
mode 600), which is never committed. One token per client, so each can be revoked alone.
- **LAN and VPN only.** The server binds one address you choose (loopback by default). Don't
bind `0.0.0.0` and don't port-forward it: reach it from outside over your VPN.
## Endpoints and tools
| Path | Auth | Purpose |
|---|---|---|
| `/mcp` | Bearer token | MCP over streamable HTTP |
| `/graph` | none, private networks only (`GRAPH_ALLOW_CIDRS`) | Interactive graph, rebuilt when `wiki/` changes |
| `/healthz` | none | `200 {"status":"ok",...}`, or `503` if the vault is unreachable |
Read-only tools: `get_index`, `search(query, limit, type)`, `read_note(title)` (title, then
alias, case-insensitive; suggests close matches), `backlinks(title)`,
`list_notes(type, tag, status)`, `recent_changes(n)`, `audit()`, `inbox_status()`.
Write tool: `contribute(title, body, kind, related, source_url)`.
Resources: `wiki://index`, `wiki://note/{title}`.
## The vault
The server expects a folder laid out like this. [`examples/vault`](examples/vault) is a starter
vault you can copy, including [`CLAUDE.md`](examples/vault/CLAUDE.md), the rulebook the curator follows:
```
raw/ your drop-zone for source material
inbox/ where `contribute` writes
_processed/ ingested files are archived here
wiki/ the curated notes: index.md, log.md, MAP.md, concepts/, entities/
scripts/ audit_graph.py and build_graph.py
CLAUDE.md the rulebook
```
The vault is plain files. Don't make it a git repo that the curator can push from, and keep
this repo's `secrets/`, `logs/` and `backups/` outside it.
## Setup
1. **Put the vault somewhere the Docker host can read.** A local folder works. For a NAS
share over NFS, a line like this in `/etc/fstab` keeps an outage from hanging the host:
```
nas.example:/volume1/SecondBrain /mnt/secondbrain nfs4 soft,timeo=30,retrans=3,noatime,bg,tcp,actimeo=5,nosharecache,_netdev,nofail 0 0
```
Keep `actimeo` low, or edits made in Obsidian take a long time to show up in search.
2. **For a network mount, add placeholders under the mount point** (see
[If the vault is unavailable](#if-the-vault-is-unavailable)). With the share unmounted,
create root-owned, read-only `wiki/ scripts/ raw/inbox/ raw/_processed/` and a
`NAS-NOT-MOUNTED` marker file in the mount point.
3. **Build the image:** `docker build -t second-brain-mcp:0.1.0 .`
4. **Tokens:** `install -d -m 700 secrets && install -m 600 /dev/null secrets/tokens`, then
add clients (below). The container runs as uid 1000 and must be able to read this file.
5. **Configure:** `cp .env.example .env` and set `BIND_IP` to the host's LAN address,
`VAULT_HOST_PATH` to the vault, and `OWNER_NAME` to your name.
6. **Deploy:** `docker compose up -d`. In Portainer, paste `docker-compose.yml` into a new
stack instead and give `SECRETS_DIR_HOST` an absolute path. `pull_policy: never` stops
either tool trying to pull the locally built image.
7. **Curator (optional):** see [The curator](#the-curator).
## Operations
```bash
curl -s http://<host>:8765/healthz # health + note and inbox counts
docker logs --tail 50 second-brain-mcp # server log (rotated 3 x 10 MB)
systemctl list-timers second-brain-curator.timer # next curator run
journalctl -u second-brain-curator -n 50 # curator summaries and failures
tail logs/curator.log # one line per run
curator/run-curator.sh # run the curator now (skips if nothing is pending)
curator/run-curator.sh --test-notify # check the Home Assistant push
```
**Upgrading:** bump `version` in `pyproject.toml`, build the image with the new tag, set
`SB_VERSION` to match and redeploy.
**Development:** `uv sync --extra dev`, then `uv run pytest && uv run ruff check . && uv run mypy`.
## Adding a client
```bash
printf 'laptop:%s\n' "$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" >> secrets/tokens
grep '^laptop:' secrets/tokens # copy the token
```
The server re-reads the file when it changes, so no restart is needed. On the client machine:
```bash
claude mcp add --scope user --transport http second-brain http://<host>:8765/mcp \
--header "Authorization: Bearer <token>"
claude mcp get second-brain # expect: Connected
```
Then tell that machine's sessions when to use it, in `~/.claude/CLAUDE.md`:
```markdown
## Second Brain (MCP server `second-brain`)
- Before asking me for background I may already have recorded, call `get_index` or `search`
first, then `read_note`.
- When a session produces something durable (a decision and its reason, a fact about my
systems, a short summary of completed work), call `contribute`. Don't contribute chit-chat,
guesses or anything secret.
- The wiki is read-only to you. Contributions land in `raw/inbox/` and a curator merges them
later, so don't expect one to show up in `search` straight away.
```
The label becomes `origin_host` on that machine's contributions and appears in their
filenames, so use a short machine name. Hosted clients whose connectors run in the vendor's
cloud can't reach a LAN-only server.
## Revoking or rotating a client
- **Revoke:** delete the client's line from `secrets/tokens`. It takes effect on the next request.
- **Rotate:** replace the token after the `label:` with a new one, then on that machine run
`claude mcp remove second-brain -s user` and add it again.
- Rotate any token that has been pasted into a chat, ticket or screenshot.
## The curator
`curator/run-curator.sh` turns inbox files into wiki edits. It needs Claude Code installed
and signed in for the user that runs it. The script:
1. Takes a lock and checks the vault is really mounted, not the placeholder.
2. Backs up the whole vault to `backups/vault-<ts>.tar.gz` (last 30 kept).
3. Exits if `raw/inbox/` and `raw/` are empty, without calling a model.
4. Otherwise runs `claude -p` with `curator/prompt.md`, restricted like this:
- `--strict-mcp-config` with no servers, so the run has no connectors at all
- `--permission-mode dontAsk` and `--setting-sources project`
- `curator-settings.json`: Edit/Write only in `wiki/**`; denies `mv`, `rm`, `curl`, `wget`,
`ssh`, `git`, web access, scheduling and sub-agents
- a 45-minute timeout
5. Runs `archive_ingested.py`, which moves to `raw/_processed/` every file that got a new
`## [..] ingest | raw/...` entry in `wiki/log.md`. The model never moves files; deferred
files stay put.
6. Re-runs the audit and logs the result. With `NOTIFY=1` it sends a Home Assistant push on
failure, a missing vault or deferred files (`HA_URL`, `HA_NOTIFY`, and a mode-600 `HA_ENV`
file holding `HA_TOKEN=...`).
To schedule it nightly, fill in the template unit and enable the timer:
```bash
sed -e "s|YOUR_USER|$USER|g" -e "s|/opt/second-brain-mcp|$PWD|g" curator/second-brain-curator.service |
sudo tee /etc/systemd/system/second-brain-curator.service >/dev/null
sudo install -m 644 curator/second-brain-curator.timer /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now second-brain-curator.timer
```
Edit the unit's `/mnt/secondbrain` paths if your vault lives elsewhere, and set `VAULT` to match.
To undo a bad night, extract the backup from before it (`tar -xzf backups/vault-<ts>.tar.gz -C /tmp`)
and copy back what you need.
## If the vault is unavailable
This matters when the vault is a network mount.
- **Share down while running:** with a `soft` mount, reads fail with I/O errors rather than
hanging. `/healthz` returns 503 and Docker marks the container unhealthy. Tools return
"Vault unavailable" and `contribute` returns "Inbox unavailable". It recovers on its own
when the share returns.
- **Share down at boot:** `bg,nofail` lets the host boot, and the container starts against
the read-only placeholders under the mount point. It reports unhealthy, and `contribute`
fails rather than leaving notes on the host's own disk. After 10 unhealthy minutes
(`UNHEALTHY_EXIT_AFTER`) the server exits on purpose. Docker restarts it, which re-binds
the real mount once it's back. That's at most one restart per 10 minutes.
- **Curator:** the unit's `ConditionPathExists` plus the script's own mount check skip the night.
## Troubleshooting
| Symptom | Check |
|---|---|
| `claude mcp get` shows failed | `curl -s http://<host>:8765/healthz` from that machine; a wrong token gives 401 in `docker logs` |
| 401 with the right token | Token line malformed (`label:token`, token at least 16 chars). The log says "Ignoring unreadable tokens file" and keeps the last good set |
| `/healthz` 503 "index.md missing" | Vault not mounted: `findmnt <mount point>`, then mount it. The container recovers within 10 minutes or on restart |
| Search misses a note you just edited | NFS attribute cache plus the index recheck (2 s); wait a few seconds |
| `/graph` 403 | Your client IP isn't in `GRAPH_ALLOW_CIDRS` |
| Curator never runs | `systemctl status second-brain-curator.timer`; `journalctl -u second-brain-curator` |
| A file sits in the inbox for days | It was deferred: read its `deferred` entry in `wiki/log.md`, then delete it or ingest it yourself in an interactive session |
| Redeploy fails pulling the image | Keep `pull_policy: never` and build the image locally first |
## Licence
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues