Skip to main content
Glama
gmoorevt
by gmoorevt

obsidian-second-brain

An Obsidian vault that an AI assistant can file into and search — self-hosted, plain Markdown on disk, no third party holding the notes.

Say "file this under Work" to Claude on your phone and a properly formed note lands in a vault on your own server, front matter filled in. Ask "what did I work out about chunking strategies" six months later and get it back, even though no note contains that phrasing.

The clever part isn't the AI. It's that filing costs almost nothing, so you actually do it. The vector index is what makes a pile of notes you never re-read still worth having.


Contents


Related MCP server: Obsidian MCP Server

How it works

Three containers share one vault directory. Two dependencies live outside: a Postgres with pgvector, and any OpenAI-compatible embeddings endpoint.

  Claude apps                        ┌─────────────────────────────────────┐
  (desktop, mobile,                  │  host                               │
   Claude Code)                      │                                     │
        │                            │   ┌───────────────┐                 │
        │  MCP over HTTPS            │   │  cloudflared  │  optional       │
        │  + bearer token            │   └───────┬───────┘                 │
        ▼                            │           │ :7620                   │
  ┌──────────────┐   outbound tunnel │           ▼                         │
  │  Cloudflare  │◄──────────────────┼── ┌──────────────────┐              │
  │  (optional)  │   no inbound port │   │ second-brain-mcp │──┐ rw        │
  └──────────────┘                   │   └──────────────────┘  │           │
                                     │                         ▼           │
                                     │   ┌──────────────────┐ ┌──────────┐ │
                                     │   │     indexer      │─┤  vault/  │ │
                                     │   │  polls every     │ │  *.md    │ │
                                     │   │  300s            │ └──────────┘ │
                                     │   └────────┬─────────┘      ▲       │
                                     │            │                │ rw    │
                                     │            │        ┌───────┴─────┐ │
                                     │            │        │  Obsidian   │ │
                                     │            │        │  (your app, │ │
                                     │            │        │   synced)   │ │
                                     │            │        └─────────────┘ │
                                     └────────────┼──────────────────────  ┘
                                                  │
                            ┌─────────────────────┴──────────────┐
                            ▼                                    ▼
                  ┌───────────────────┐              ┌──────────────────────┐
                  │ Postgres+pgvector │              │  embeddings endpoint │
                  │  chunks + vectors │              │  OpenAI-compatible   │
                  └───────────────────┘              └──────────────────────┘

Filing is decoupled from indexing. file_note returns as soon as the file is on disk. The note is findable by structured search immediately and by semantic search within one indexer cycle. A wedged embedding backend never slows down anything you are waiting on.

The indexer scans the vault rather than waiting to be told, so notes you type by hand in Obsidian are indexed exactly like notes the assistant files. That matters more than it sounds — most vaults end up mostly hand-written.


The MCP tools

Five tools over MCP's streamable-HTTP transport.

Tool

Does

Depends on

file_note

Writes a note into <Segment>/Inbox/ with valid front matter

vault disk

search_notes

Filters by segment, tag, status, date range, filename, literal text

vault disk

get_note

Returns one note in full, by vault-relative path

vault disk

semantic_search

Cosine top-k over note chunks

Postgres + embeddings

index_status

Index freshness and embedding-model consistency

Postgres

Three of the five never leave the filesystem. That is deliberate: filing and keyword search keep working when the model server is down, which on a homelab it regularly is.

Plus three plain HTTP routes for monitoring:

Route

Answers

GET /healthz

process is alive

GET /readyz

vault mounted, segment inboxes present

GET /index-health

index fresh, and built with the model currently configured


The /pub read façade

Some Claude deployments — a managed work tenant, for instance — do not allow custom connectors at all. The /pub routes are a read-only fallback that rides on an ordinary web fetch instead.

All GET, all returning text/markdown, all under /pub/<token>/:

Route

Backed by

/

self-describing index: lists routes, parameters, segments

search?q=

search_notes — filesystem only

semantic?q=

semantic_search — 503 with a pointer to search if the index is down

note?path=

get_note, paged via offset

Markdown rather than JSON, because it survives a fetch tool intact and reads correctly to a model on the far side. Responses carry Cache-Control: no-store (a capability URL in a shared cache is a leaked vault) and X-Robots-Tag: noindex, nofollow.

Set PUBLIC_READ_TOKEN to enable. Leave it unset and these routes return 404 — a prober learns nothing about whether they exist.

The handlers call the same functions the MCP tools call. They are a second transport, never a second implementation: a divergent search would show up as two clients disagreeing about what is in the vault.

The token is in the URL. That is what makes it work with a fetch tool, and it is a real cost: the token appears in proxy logs, in any reverse-proxy access log, and in browser history. Treat it as a read-only, rotatable credential and nothing more. The service redacts /pub/<token> from its own logs; it cannot redact anyone else's.


POST /ingest

A write path for the same constrained clients, taking a write-scoped token in an Authorization header rather than in a URL.

POST /ingest
Authorization: Bearer sbt_...
Content-Type: application/json

{
  "title":   "What I learned about HNSW",
  "body":    "# ...markdown...",
  "segment": "Industry",
  "tags":    ["pgvector", "retrieval"],
  "idempotency_key": "some-stable-id"
}

The idempotency key means a poller that retries does not duplicate the note.

Tokens live in a SQLite store (TOKEN_DB_PATH), not in Postgres — authentication has to keep working when the database does not. They are prefixed sbt_, stored hashed, carry a scope (read < write < admin), and can be individually expired or revoked with a recorded last-used time.


Requirements

Need

Notes

A Docker host

One small VM or LXC. 2 vCPU / 4 GB is comfortable

Postgres with pgvector

Any Postgres 14+. You need one schema, not a cluster

An embeddings endpoint

Anything OpenAI-compatible: Ollama, llama.cpp, LM Studio, Lemonade, or OpenAI

Obsidian

Optional but the point — any sync mechanism works

On syncing the vault to your devices: Obsidian Sync has no CLI and no headless mode, so if you use it, a real Obsidian has to run somewhere always-on. The linuxserver/obsidian image works for this. Self-hosted alternatives (LiveSync over CouchDB, Syncthing) avoid that entirely. Whatever you choose, run exactly one syncer — two over one vault is how you get conflict files and lost edits.


Quick start

git clone https://github.com/gmoorevt/obsidian-second-brain.git
cd obsidian-second-brain
cp .env.example .env

1. Apply the database schema.

psql "$DATABASE_URL" -f schema.sql

schema.sql declares vector(1024), which matches Qwen3-Embedding-0.6B. Change it to match your model or every insert fails. bge-m3 is 1024, OpenAI text-embedding-3-small is 1536, nomic-embed-text is 768.

2. Verify your embeddings endpoint — the dependency most likely to be subtly wrong:

curl -s http://localhost:8080/v1/embeddings \
  -H 'Content-Type: application/json' \
  -d '{"model":"YOUR-MODEL","input":"hello"}' \
  | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"][0]["embedding"]))'
# → must equal the N in vector(N)

3. Scaffold the vault.

./vault/scaffold-vault.sh ./data/vault

Idempotent — safe to re-run, creates only what is missing, never overwrites a note. Use --dry-run first if you like.

4. Fill in .env — at minimum MCP_BEARER_TOKEN (openssl rand -hex 32), DATABASE_URL, and EMBEDDING_ENDPOINT. Delete the cloudflared service from docker-compose.yml if you are not publishing the endpoint.

5. Start it.

docker compose up -d --build
curl -s http://127.0.0.1:7620/healthz     # → ok

6. Connect your client to https://your-host/mcp with the bearer token as Authorization: Bearer <MCP_BEARER_TOKEN>.

Round-trip test: "File a note in Projects titled Connector Test", then "search my Projects inbox".


Configuration

Variable

Default

Meaning

MCP_BEARER_TOKEN

required

Shared secret for the MCP endpoint. Min 32 chars

PUBLIC_READ_TOKEN

unset

Enables /pub. Unset ⇒ those routes 404

TOKEN_DB_PATH

/state/tokens.db

SQLite token store

VAULT_ROOT

/vault

Vault path inside the container

MCP_HOST

0.0.0.0

Bind address inside the container

MCP_PORT

7620

Listen port

BIND_ADDR

127.0.0.1

Host address Compose publishes on

VAULT_DIR

./data/vault

Host path to the vault

STATE_DIR

./data/state

Host path for the token store

DATABASE_URL

required for semantic

postgres://user:pw@host:5432/db

EMBEDDING_ENDPOINT

required for semantic

OpenAI-compatible base URL

EMBEDDING_MODEL

Also recorded per chunk, to detect model drift

EMBEDDING_TIMEOUT

30

Seconds before an embedding call fails

INDEX_INTERVAL_SECONDS

300

Indexer poll interval

INDEX_STALENESS_SECONDS

1800

Age at which /index-health reports unhealthy

LOG_LEVEL

INFO

Standard Python levels

TUNNEL_TOKEN

unset

cloudflared run-token, if publishing


Vault conventions

Deliberately shallow — four segments, two subfolders each, nothing deeper. Depth is where vaults like this die.

Work/  Industry/  Projects/  Life/
  Inbox/       ← everything new lands here
  Notes/       ← reviewed and kept

Attachments/   images, PDFs, binaries
Daily/         quick capture; no front matter required
_templates/    capture templates

Every note in a segment carries exactly six fields. Daily/ is exempt — that folder is a zero-friction scratchpad and requiring structure there defeats it.

---
title: Human readable title
created: 2026-09-07
segment: Industry          # Work · Industry · Projects · Life
tags: [rag, obsidian]      # flat list, no parent/child hierarchies
source: claude-desktop     # which channel wrote it
status: inbox              # inbox · filed
---

Filenames are YYYY-MM-DD-slugged-title.md. Collisions take a numeric suffix; nothing is ever overwritten.

vault/conventions.yaml is the single source of truth for all of this, read by both the scaffolding script and the service so they cannot disagree about the segment list. It is duplicated at src/second_brain_mcp/conventions.yaml because the package ships with it — keep the two identical.

Review is optional, and saying so out loud is what makes this survive. status: inbox exists so a review pass is possible, not owed. A note that sits at inbox for a year is still fully searchable. A system that quietly guilts you is a system you abandon.


Design notes

The decisions that were not obvious, and why.

The indexer is a separate container from the server. Same image, different entrypoint. A slow or wedged embedding backend must not make filing slow, and the two failures need to be separately visible to monitoring.

The Postgres connection is lazy. Built on first use, not at import. If it were eager, a database outage would stop the whole service from starting — including file_note, which needs no database at all.

Auth runs before parameter validation and any filesystem access. FastMCP ships a StaticTokenVerifier whose own docstring says not to use it in production. The replacement compares with hmac.compare_digest so the check does not leak the token by timing, rejects anything under 32 characters at startup, and never logs the supplied value — logging a near-miss token puts a credential in the log file.

Chunking splits on Markdown headings first, then windows anything still too long at 2,000 characters with 200 of overlap. Each chunk keeps its heading, so a hit can say which section it came from.

embedding_model is recorded on every chunk. Swap models and your existing vectors become meaningless — cosine distance across two embedding spaces returns confident nonsense rather than an error. Recording the model turns a silent corruption into a visible mismatch in index_status.

Don't share the tunnel's network namespace. Giving cloudflared network_mode: service:second-brain-mcp looks tidier and lets you route to localhost. It also couples the lifecycles: restarting the service gives it a new namespace and leaves cloudflared attached to the dead one. Service reports healthy, cloudflared reports healthy, public endpoint returns 530. Reach it by container name over the compose network instead.

A stalled sync driver looks exactly like nothing being wrong. Filing succeeds, the assistant reports a path, and the note simply never reaches your phone. Monitor sync separately from the endpoint.


Security

Read this before exposing the service to the internet.

The endpoint writes files to disk from network input. Treat it accordingly.

  1. Put an allowlist in front of it. If only one client should ever reach this hostname, restrict it at the edge to that client's egress range — a Cloudflare WAF custom rule, or equivalent. A bearer token alone is one layer, and one layer on a public write endpoint is thin. Anthropic publishes its egress range at https://platform.claude.com/docs/en/api/ip-addresses; verify it rather than copying a range out of a blog post, this one included.

  2. Do not route /ingest publicly. If the client that posts to it runs on your own network, reach it over the LAN and block the path at the edge. A route that is not routed cannot be misconfigured into being public.

  3. /pub tokens are capability URLs. Anyone holding one can read the whole vault. Rotate them, scope them read-only, and remember that your reverse proxy and CDN log full request paths even though this service does not.

  4. Bind to loopback unless you need otherwise. BIND_ADDR defaults to 127.0.0.1. Setting a LAN address publishes every route on the LAN, /pub included.

  5. The token store is not in the vault. Keep it that way — credentials must not sync to every device Obsidian is open on.

This project has had no external security review. It is a personal homelab tool that is useful enough to share, not a hardened product.


Development

pip install -e ".[dev]"
pytest

96 tests, no network or database required — the suite covers the token store, the public façade rendering, redaction, and clamping.

Layout:

Path

Responsibility

src/second_brain_mcp/server.py

FastMCP app, tools, HTTP routes

src/second_brain_mcp/index.py

pgvector reads/writes, run bookkeeping

src/second_brain_mcp/notes.py

Front matter parsing, structured search

src/second_brain_mcp/vault.py

Path resolution, traversal refusal, safe writes

src/second_brain_mcp/public.py

/pub rendering, log redaction

src/second_brain_mcp/tokens.py

SQLite token store, scopes

src/second_brain_mcp/chunking.py

Heading-aware splitting, hashing

src/second_brain_mcp/embedding.py

Embedding client, typed failures

src/second_brain_mcp/conventions.py

Loads and validates conventions.yaml

src/second_brain_mcp/auth.py

Constant-time bearer verification

src/second_brain_mcp/indexer.py

The polling loop


License

MIT — see LICENSE.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gmoorevt/obsidian-second-brain'

If you have feedback or need assistance with the MCP directory API, please join our Discord server