Skip to main content
Glama
mmsge

fenced-obsidian-sync-mcp

by mmsge

fenced-obsidian-sync-mcp

A deny-by-default, finely fenced MCP server over an Obsidian vault. The operator declares exactly which capabilities (list, read, create, …) an agent gets, each scoped by path globs — and everything else is impossible by construction, not merely guarded.

Other Obsidian MCP servers hand an agent broad read/write/search/delete. Here the fence is the product: you can run it so an agent may only create notes under Inbox/, or only see note titles, or touch nothing at all — and prove the rest is impossible because the tools are never registered.

Full design brief: docs/SPEC.md. The "Core principles (security invariants)" there are requirements, and each is backed by a test.

Install

pip install -e ".[http,dev]"     # http extra for remote serving; dev for tests

Requires Python ≥ 3.10.

Run

fenced-obsidian-sync-mcp --config examples/create-only.yaml
# or
python -m fenced_obsidian_sync_mcp --config examples/create-only.yaml

By default this speaks stdio (for a local MCP client). Set transport.type: http for remote serving (see below).

The three canonical postures

Each is a complete, runnable config (full files in examples/).

1. Deny-all — a dark vault

The server runs but exposes no tools at all.

mode: local
vault: /path/to/your/vault
capabilities: {}

2. Create-only — file, never read

The agent can drop new notes into Inbox/ and do nothing else. No read/list/update/delete tool is registered. Collisions never overwrite.

mode: local
vault: /path/to/your/vault
collision: suffix          # fail | suffix
capabilities:
  create:
    enabled: true
    allow: ["Inbox/**"]

3. Titles-only — see names, not bodies

The agent learns which notes exist (to suggest links) but no registered tool can open their contents. read and read_metadata are absent.

mode: local
vault: /path/to/your/vault
capabilities:
  list:
    enabled: true
    allow: ["**/*.md"]
    deny:  ["Private/**", "Journal/**"]

Capabilities

Every capability is off by default and independently path-scoped. Enabling one registers exactly one tool; disabling it means the tool is absent from the MCP tool list.

Capability

Tool

Exposes

Opens file contents?

list

list_notes

enumerate paths / filenames

no — readdir only

read_metadata

read_metadata

frontmatter, tags, stat

frontmatter only

read

read_note

full note contents

yes

search

search_notes

content search (implies read)

yes

create

create_note

new files only, never overwrite

no

update

update_note

modify / append existing files

n/a

delete

delete_note

remove files

n/a

move

move_note

rename / relocate, never overwrite

n/a

search implies read: enabling search without read is a config error, because returning snippets while read is off would be a content oracle.

Glob semantics

Globs use wcmatch with GLOBSTAR, evaluated against vault-relative POSIX paths:

  • ** spans directories (**/*.md matches at any depth); * matches within a single segment.

  • Case-sensitive.

  • Dotfiles are not matched unless a pattern names the leading dot, so .obsidian/ stays out of **/*.md.

  • deny always beats allow on every capability.

Modes

  • mode: local — point vault at a directory you already keep current (Syncthing, iCloud, git, or nothing). No sync subprocess, no obsidian-headless dependency. Works anywhere, including a phone via Termux.

  • mode: syncvault is your remote Obsidian Sync vault name and vault_dir is the local directory the official obsidian-headless client (ob sync --continuous) mirrors into. The server supervises that process.

The fence is identical in both modes; only how the directory stays current differs.

⚠️ Sync-mode write warning: in sync mode, update/delete/move propagate to every device on your Obsidian Sync. Create-only is the conflict-free default; enable writes deliberately.

Transports

  • stdio (default) — for a local MCP client.

  • HTTP (transport.type: http) — streamable-http behind a bearer token (required) and TLS. Terminate TLS in-process (transport.tls.certfile / keyfile) or at a reverse proxy such as Caddy. Full OAuth is a future extension; bearer + TLS is the supported remote posture today.

transport:
  type: http
  host: 0.0.0.0
  port: 8080
  auth: { bearer_token: "a-long-random-secret" }
  tls:  { certfile: /etc/ssl/certs/server.crt, keyfile: /etc/ssl/private/server.key }

Audit log

Optional structured JSONL of every allowed and denied call (capability, path, decision, and — for denials — an internal reason never shown to the client):

audit:
  enabled: true
  path: /var/log/fenced-obsidian-sync-mcp/audit.jsonl   # omit -> stderr

Deploy with Docker (mode: sync)

The repo ships a Dockerfile, docker-compose.yml, and Makefile for running mode: sync behind a reverse proxy (e.g. the central Caddy on a Hetzner box). The image bundles both the Python server and the official obsidian-headless (ob) client; TLS is terminated at the proxy, so the container speaks plain HTTP on 0.0.0.0:4012 and the bearer token comes from $FOSM_BEARER_TOKEN.

# 1. one-time obsidian-headless auth (persists in named volumes)
make ob-login                      # interactive: email, password, MFA
make ob-setup VAULT="My Vault"     # links the remote vault into /vault

# 2. config + secret
cp examples/config.docker-sync.yaml config.yaml   # edit vault name + globs
cp .env.example .env && echo "FOSM_BEARER_TOKEN=$(openssl rand -hex 32)" > .env

# 3. run
make deploy                        # docker compose up -d --build

bearer_token_env: FOSM_BEARER_TOKEN in the config sources the token from the container environment, keeping the secret out of the config file and git. Point your reverse proxy at 172.18.0.1:4012 (the Docker bridge gateway) and, because MCP streamable-http uses SSE, disable proxy buffering — in Caddy: reverse_proxy 172.18.0.1:4012 { flush_interval -1 }.

Security invariants

These are guaranteed and tested (tests/):

  1. Deny by default — a disabled capability's tool is not registered.

  2. Capabilities are independent — knowing a note exists (list) is separate from its metadata (read_metadata) is separate from its body (read).

  3. Path confinement.., absolute paths, escaping symlinks, and percent-/double-encoded separators are all rejected.

  4. No silent overwritecreate uses O_EXCL; collisions fail or suffix.

  5. Glob-scoped, deny-winsdeny always beats allow.

  6. No existence oracle — denied paths return a uniform not permitted.

  7. Auditable — optional structured log; a small, readable codebase.

Development

pip install -e ".[http,dev]"
pytest        # full invariant + acceptance suite
ruff check .

License

MIT