Skip to main content
Glama
sipho102
by sipho102
README.md
# brain-mcp

<img src="unraid/icon.png" width="120" alt="brain-mcp icon" />

*Icon design credit: [wenmeiZhou](https://www.istockphoto.com/portfolio/wenmeiZhou?mediatype=illustration)
on iStock.*

An MCP server that exposes a flat, retrieval-first markdown second-brain
vault (Obsidian-compatible) as a set of read tools plus a single constrained
write tool (`capture`, which only ever creates new notes in `00-inbox/`).
Runs as a Docker container, consumed over streamable-HTTP by Claude Code,
opencode, and other MCP clients. It can also run locally over stdio (see
"Running over stdio" below) for clients that launch the server as a
subprocess instead of connecting to a shared network instance.

Full behavioural spec: see `brain-mcp-spec.md` in this repo (or wherever you
keep it) if you need the "why" behind a design choice.

## Notes on the CONVENTIONS.md parser

`brain_structure()` reads the `type`/`status`/`topic` enums live out of
your vault's `90-meta/CONVENTIONS.md` rather than hardcoding them (see
`_extract_enum_values` in `src/brain_mcp/vault.py`). It's been checked
against a real vault's file directly: that file states the three enums as
bold inline labels under one `## Enums` heading (`**type:** \`document\`,
\`memory\`, ...`) rather than each getting its own subheading, so
`_extract_enum_values` tries that shape first (bounded to the label's own
paragraph, so it doesn't pick up unrelated backtick-quoted words in the
prose below — e.g. "`topic` is the organising axis..."), falling back
to a heading-based heuristic for vaults that document enums differently. If
you restructure `CONVENTIONS.md`'s Enums section later, re-check this
parser — the server fails loudly at startup rather than falling back to bad
defaults if it can't parse a non-empty value list for all three fields.

**No `uid` field:** identity is the filename, which is unique vault-wide by
convention — `read_note`/`get_backlinks` accept either a vault-relative
path or a bare filename (with or without `.md`), and error out (listing
candidates) rather than guess if a bare filename somehow matches more than
one note.

## Requirements

- The vault's flat structure and frontmatter schema as described in the
  vault's own `90-meta/CONVENTIONS.md`.
- Docker (or Docker Compose) on the Unraid box, or Python 3.12 + `uv`
  locally for development.
- `ripgrep` on PATH (bundled in the container image; install separately for
  local dev).

## Configuration

All configuration is via environment variables — nothing about a specific
vault (path, name, token) is hardcoded, so the same image serves any number
of sibling vaults as separate containers.

| Variable         | Required        | Default   | Meaning                                            |
|------------------|-----------------|-----------|-----------------------------------------------------|
| `BRAIN_ROOT`     | yes             | —         | Absolute path to the vault root in-container         |
| `BRAIN_NAME`     | yes             | —         | Instance name, e.g. `personal` or `family`           |
| `BRAIN_TRANSPORT`| no              | `http`    | `http` (streamable-HTTP) or `stdio`                  |
| `BRAIN_TOKEN`    | only if `http`  | —         | Bearer token required on every MCP request           |
| `PORT`           | no              | `3100`    | Listen port (`http` only)                            |
| `BIND_ADDRESS`   | no              | `0.0.0.0` | Listen address (`http` only)                         |
| `LOG_LEVEL`      | no              | `INFO`    | `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`          |

`BRAIN_TOKEN` gates network access, so it's not required in `stdio` mode:
stdio is a local pipe to whichever single process launched the server, with
no network exposure to gate.

Copy `.env.example` to `.env` and fill in `BRAIN_NAME`, `BRAIN_VAULT_PATH`
(the host path to your vault), and `BRAIN_TOKEN` (a random secret —
`openssl rand -hex 32` works well) before running Compose.

## Local development

```bash
uv sync --dev          # or: python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
uv run pytest          # or: .venv/bin/python -m pytest
```

Tests run entirely against a synthetic fixture vault built in
`tests/conftest.py` — never against real data.

To run the server locally against a real (or scratch) vault directory:

```bash
export BRAIN_ROOT=/path/to/vault
export BRAIN_NAME=personal
export BRAIN_TOKEN=dev-token
uv run brain-mcp
```

## Running over stdio

For MCP clients that launch the server as a local subprocess (rather than
connecting to a shared network instance), set `BRAIN_TRANSPORT=stdio`. No
`BRAIN_TOKEN`, `PORT`, or `BIND_ADDRESS` is needed:

```bash
export BRAIN_ROOT=/path/to/vault
export BRAIN_NAME=personal
export BRAIN_TRANSPORT=stdio
uv run brain-mcp
```

Example Claude Code / Claude Desktop config:

```json
{
  "mcpServers": {
    "brain": {
      "command": "uv",
      "args": ["run", "brain-mcp"],
      "cwd": "/path/to/brain-mcp",
      "env": {
        "BRAIN_ROOT": "/path/to/vault",
        "BRAIN_NAME": "personal",
        "BRAIN_TRANSPORT": "stdio"
      }
    }
  }
}
```

This mode is per-client (one process per session, no sharing across
clients) — the same tradeoff any stdio MCP server makes. Use `http` mode
(the default) if you want one running instance shared by multiple clients
or machines, which is what the Docker/Unraid setup below is for.

## Running on Unraid

```bash
cp .env.example .env   # fill in BRAIN_NAME, BRAIN_VAULT_PATH, BRAIN_TOKEN
docker compose up -d --build   # or: docker compose pull && docker compose up -d
curl http://<unraid-host>:3100/health
```

`--build` builds from this checkout; `pull` instead fetches the same
image prebuilt from GHCR (see "Installing as an Unraid app" below) —
either produces the `ghcr.io/sipho102/brain-mcp:latest` tag locally.

The compose file mounts `BRAIN_VAULT_PATH` read-only and re-mounts just
`00-inbox/` read-write on top of it:

```yaml
volumes:
  - ${BRAIN_VAULT_PATH}:/vault:ro
  - ${BRAIN_VAULT_PATH}/00-inbox:/vault/00-inbox:rw
```

This is intentional and load-bearing — even a bug in the write path can't
touch anything outside the inbox, regardless of what the Python code thinks
it's doing. Don't simplify it to a single read-write mount.

### Installing as an Unraid app instead

If you'd rather manage this from Unraid's Docker tab like any other app —
a form instead of editing `.env`, a Start/Stop/Update button afterward —
there's a template for that in `unraid/brain-mcp.xml`. `.github/workflows/
publish.yml` builds this repo's image and publishes it to GHCR
(`ghcr.io/sipho102/brain-mcp:latest`) on every push to `main`, and the
template pulls that directly — no cloning or building on the Unraid box
at all.

Make the template available to Unraid:

- **Recommended** — in the Docker tab, **Add Container** and paste this
  repo's raw template URL directly into the template field:
  `https://raw.githubusercontent.com/sipho102/brain-mcp/main/unraid/brain-mcp.xml`
  Nothing gets written to Unraid's local templates folder this way, so
  there's no stray file left behind to conflict with later — see the
  caveat below on the alternative method.
- Or copy it into Unraid's local templates folder over SSH first:
  ```bash
  curl -o /boot/config/plugins/dockerMan/templates-user/brain-mcp.xml \
    https://raw.githubusercontent.com/sipho102/brain-mcp/main/unraid/brain-mcp.xml
  ```
  It'll then show up under **Docker → Add Container → template
  dropdown** — but see the note right after Add Container about deleting
  this file once the container exists.

Either way, you'll get a form for the vault path, the inbox path (must be
`<vault path>/00-inbox` — the template can't derive it for you), instance
name, and bearer token; everything else is pre-filled with sane defaults
under "advanced view".

**If you used the local-copy method above, delete that seed file once the
container's been added:**
```bash
rm /boot/config/plugins/dockerMan/templates-user/brain-mcp.xml
```
When you click Apply on Add Container, Unraid saves a *second* file with
your actual values — `my-brain-mcp.xml`, alongside the blank one you
downloaded — and both declare the same container name. With two templates
claiming that name, **Update can end up recreating the container from the
blank original instead of your saved one, wiping BRAIN_NAME/BRAIN_TOKEN/
the paths and leaving it unable to start.** Once `my-brain-mcp.xml` exists
(check `ls /boot/config/plugins/dockerMan/templates-user/`), the seed file
has done its job and isn't needed — remove it so there's no ambiguity.
Clicking **Update** afterward pulls whatever's newest on
`ghcr.io/sipho102/brain-mcp:latest` using your saved config, as expected.

### Serving a second vault

One container serves one vault — there's deliberately no multi-vault
service list in `docker-compose.yml`, and no multi-vault form in the
Unraid template either. On the Unraid-app path above, that just means
running **Add Container** again from the same template with a different
name/paths/token/port. On the Compose path, copy this deployment directory
(or just `docker-compose.yml` + `.env`) elsewhere, fill in that copy's
`.env` with a different `BRAIN_NAME`,
`BRAIN_VAULT_PATH`, `BRAIN_TOKEN`, and `PORT`, and run `docker compose up
-d --build` from there too. Same image (`brain-mcp:latest`), independent
containers.

### Container user / permissions

The container runs as a non-root user, UID:GID `99:100` by default (Unraid's
`nobody:users`) — override at build time with `BRAIN_UID`/`BRAIN_GID` in
`.env` if your share needs different ownership. This user must have write
access to `00-inbox/` on the host share.

## Connecting a client

### Claude Code

```bash
claude mcp add --transport http --scope user brain \
  http://<unraid-host>:3100/mcp \
  --header "Authorization: Bearer <token>"
```

Then `/mcp` in a session should list all six tools.

**Known quirk:** Claude Code has had recurring bugs where headers set via
`--header` aren't sent during session establishment, producing 401s even
though `curl` with the same token works fine. If you hit that, write the
`headers` object directly into the JSON config instead
(`~/.claude/mcp_servers.json` or the relevant scope file):

```json
{
  "mcpServers": {
    "brain": {
      "type": "http",
      "url": "http://<unraid-host>:3100/mcp",
      "headers": {
        "Authorization": "Bearer <token>"
      }
    }
  }
}
```

(`type` also accepts `streamable-http` as an alias for `http` in JSON
configs.)

### opencode

opencode attempts OAuth discovery on remote MCP servers by default and will
ignore a static bearer token unless you disable that explicitly:

```jsonc
{
  "mcp": {
    "brain": {
      "type": "remote",
      "url": "http://<unraid-host>:3100/mcp",
      "oauth": false,
      "headers": {
        "Authorization": "Bearer <token>"
      }
    }
  }
}
```

Without `oauth: false`, opencode will try (and fail) an OAuth handshake
instead of using the header.

### Clients with no header field at all

Some MCP-client UIs only take a name, transport, and URL — no way to set a
custom `Authorization` header. For those, put the token in the URL instead:

```
http://<unraid-host>:3100/mcp?token=<token>
```

The server checks the `Authorization` header first and falls back to a
`?token=` query parameter, so this works anywhere the header-based config
above does too. Worth knowing before you rely on it: a token in a URL can
end up in more places than a header would — the client's saved config, a
browser's history if the URL is ever opened directly, shell history if
you've pasted it into a terminal. Access logs aren't a concern here
(`uvicorn`'s access log is off), but treat the URL itself as carrying the
secret, the same as you would the token itself.

## Tools

Six tools, kept deliberately small (tool schemas cost client context):

- `brain_structure()` — orientation: top-level folders + counts, live enums
  from `CONVENTIONS.md`, frontmatter schema, full conventions text, note
  count. Call this first in a session.
- `search_notes(query, topic, type, status, limit)` — full-text search
  (ripgrep) with frontmatter filtering. Returns metadata + a ~200 char
  snippet, never full bodies.
- `read_note(identifier)` — full note by vault-relative path or bare
  filename (unique vault-wide).
- `list_notes(topic, status, type, limit)` — metadata-only browsing, no
  content search.
- `get_backlinks(identifier)` — notes that link to this one, with the
  context line.
- `capture(title, body, topic, when_to_open, source, type, kind, confidence, supersedes, links)`
  — the only write: creates a new note in `00-inbox/` per `CAPTURE.md`'s
  rules. Never overwrites, never touches anything outside the inbox.

## What this deliberately doesn't do

No semantic search/embeddings, no write access outside `00-inbox/`, no
Obsidian Local REST API dependency (reads the filesystem directly), no
paperless-ngx document fetching (returns document IDs from frontmatter for a
client to chain to a separate paperless MCP server), no git operations. See
`brain-mcp-spec.md` §2 for the reasoning.