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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。