second-brain-mcp
An MCP server that exposes an Obsidian vault to Claude, enabling reading, searching, writing notes, capturing voice thoughts, and managing tasks. It enforces vault filing rules in code, with path confinement and no hard deletes for safety.
README
second-brain-mcp
An MCP server that exposes an Obsidian vault to Claude — read, search, and write notes, capture
voice thoughts into the right folder with the right frontmatter, and manage Tasks.md.
Built so the vault's filing rules live in code rather than in a prompt that can be forgotten.
Status
| Phase | State |
|---|---|
| 1. Local, stdio, five primitives | Done, 119 tests |
| 2. HTTP transport + bearer auth | Done, verified locally |
| 2b. Container image | Written but never built — no Docker daemon on the dev machine |
| 3. Expose via DDNS + reverse proxy | Not started — needs NAS access, see Deployment |
| 4. Semantic tools | Done |
Quick start
Requires uv. Python comes from uv; the system python3 is too old.
uv sync
# Point at a COPY of your vault first. Never the real one until you trust it.
rsync -a ~/Library/CloudStorage/SynologyDrive-Mergoth/Notes/PersonalObsidian/ /tmp/vault-copy/
VAULT_PATH=/tmp/vault-copy uv run python -m second_brain_mcp
That starts the stdio server. It will refuse to start without VAULT_PATH — there is no default,
deliberately, because a default is how a test run reaches the real vault.
Connect it to Claude Desktop
{
"mcpServers": {
"second-brain": {
"command": "uv",
"args": ["run", "--directory", "/Users/vladislav/work/second-brain-mcp",
"python", "-m", "second_brain_mcp"],
"env": { "VAULT_PATH": "/Users/vladislav/work/vault-sandbox/PersonalObsidian" }
}
}
}
HTTP transport
VAULT_PATH=/tmp/vault-copy MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
uv run python -m second_brain_mcp --transport http
Serves on 127.0.0.1:8000, MCP endpoint at /mcp. Every request needs
Authorization: Bearer <token>; unauthenticated requests get a 401 with a WWW-Authenticate
header pointing at /.well-known/oauth-protected-resource (RFC 9728).
Configuration
All configuration is environment variables. None have defaults.
| Variable | Required for | Notes |
|---|---|---|
VAULT_PATH |
always | Absolute path to the vault root. Resolved and frozen at startup; never re-read. |
MCP_AUTH_TOKEN |
--transport http |
Static bearer token. Compared with hmac.compare_digest. |
Tools
Primitives — no vault knowledge
| Tool | Signature |
|---|---|
list_notes |
(glob="**/*.md", since=None) — paths and mtimes, no bodies. Excludes dotfiles. |
read_note |
(path) |
write_note |
(path, content, mode) — mode is create | overwrite | append |
search_vault |
(query, scope=None) — ripgrep. Requires rg on PATH. |
move_note |
(from_path, to_path) — the only removal verb. There is no delete. |
Semantic — encodes the vault's rules
| Tool | Signature |
|---|---|
capture_thought |
(text, source, domain=None) — writes raw/thoughts/YYYY-MM-DD-HHMM-slug.md with correct frontmatter. The voice-while-driving path. |
list_task_sections |
() — the ## headers currently in Tasks.md, read live |
add_task |
(text, section, priority=None, due=None, link=None) |
get_tasks |
(filter=None) — structured tasks, flags overdue |
append_log |
(line) — appends to meta/log.md verbatim |
add_task requires an explicit section and raises listing the available ones if it doesn't
match. It does not guess and has no default section. This is deliberate: the vault's own
CLAUDE.md says "Sections are a view, not a taxonomy. Re-sort when reality moves", so any
hardcoded section table would silently misfile tasks the next time you reorganise. Call
list_task_sections() first.
add_task never invents a date or priority, and reports back any marker it added that you did not
state.
capture_thought rejects a domain outside the closed list (work finance legal health
trips home plants smart-home projects learning) rather than inventing one.
Security model
The server is a plain local-filesystem server. Its one real control is path confinement.
- Every caller-supplied path becomes a real path in exactly one function,
vault/paths.py::resolve(). Nothing else in the package opens a file by a caller-supplied path. - The vault root is resolved once at startup and frozen. It is not a tool argument and cannot be changed at runtime.
resolve()takes one parameter. There is no bypass flag, no per-call root, no trusted-path list, no follow-symlinks toggle.- Containment is checked by path ancestry, never string prefix — with root
/vault, the sibling/vault-evilmust not pass. - Rejected:
.., absolute paths, null bytes, empty paths, and symlinks that resolve outside the root even when the link itself lives inside the vault. - Glob patterns and search scopes are validated too, and the search query goes to
rgafter-eso it can never be parsed as a flag.
That last point is not theoretical. An early build appended the caller's query to rg as a bare
positional argument, so a query of --pre=<script> executed arbitrary commands — with a
model-controlled argument, which is exactly the prompt-injection threat the design exists to stop.
See factory/adr/0002-single-path-resolution-chokepoint.md.
No hard delete anywhere. Archiving is move_note into raw/archive/.
Every mutation appends a line to meta/audit.log (machine-readable, append-only). That is
deliberately a different file from meta/log.md, which stays human-curated so an unexplained line
in it is still a usable tripwire.
Development
uv run --frozen pytest -q # 119 tests
uv run ruff check .
Tests run against a synthetic fixture vault copied into tmp_path. An autouse guard fails the
session if the resolved vault root is not under tmp_path, so the suite cannot reach a real vault.
Design documents:
factory/briefs/vault-mcp-server.md— why it is built this way, and what was rejectedfactory/adr/— binding architecture decisionsspecs/— what each increment buildsdocs/initial_spec.md— the original design record
Deployment
Phase 3 is not done. What remains is NAS and browser work, not code:
- Fix the advertised metadata URLs first — this is a blocker, not a nicety.
__main__.pycallsbuild_auth_settings()with no arguments, so it advertises the defaultsresource_url="http://127.0.0.1:8000"andissuer_url="https://auth.example.com". Behind a reverse proxy those are wrong: a remote client is told the resource lives on loopback. Make both read from the environment (e.g.MCP_RESOURCE_URL,MCP_ISSUER_URL) before exposing anything. - Build the container.
deploy/Dockerfileanddeploy/compose.yamlare written — non-root user, read-only rootfs,ripgrepinstalled, port bound to127.0.0.1— but have never been built or run. - Resolve container UID vs. vault file ownership.
Tasks.mdandmeta/log.mdare mode600on the real vault, so a non-root container with a mismatched UID getsEACCESon exactly the two highest-value writes while reads ofraw/keep working — a partial failure that looks like a tool bug. - DSM reverse proxy, Let's Encrypt cert, rate limit, auto-block on failed auth. Never publish the container port directly.
- Register as a custom connector and test from Android.
On auth
docs/initial_spec.md assumed custom connectors require OAuth 2.1 with dynamic client
registration. That is out of date: DCR is deprecated in the current MCP spec (Client ID
Metadata Documents replace it), and static bearer tokens are first-class on Anthropic's MCP client
surfaces. So this ships a static bearer.
The honest caveat: that is evidence about Anthropic's API surfaces. Whether the claude.ai
custom connector UI accepts a static bearer is a product question that needs a live test. If it
turns out to demand OAuth, auth.py is the only module that changes — transport and auth are
confined to the entrypoint by factory/adr/0003.
Known limitations
- The container image is unbuilt and unverified.
- RFC 9728 metadata URLs are hardcoded defaults (see Deployment step 1).
list_notes(since)filters on filesystem mtime, which on a Synology-synced folder is sync time, not edit time. The vault'sCLAUDE.mdsayscreated:in frontmatter is the real recency anchor.- No end-to-end test drives a JSON-RPC tool call over HTTP; tools are covered over stdio and via direct calls.
propose_wiki_pagefrom the original spec is deliberately not built — its arguments were never specified and it serves deep work at the desk, where nothing is blocked.- Synology sync conflicts are not handled. Writes are atomic (temp file +
os.replace), but there is no merge logic. The original spec cited conflict files as evidence this was urgent; there are none inPersonalObsidian/, so the risk is real but unproven and was not paid for.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.