Skip to main content
Glama
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).