Filesystem/git-native FastMCP gateway serving Obsidian vaults over MCP
Project description
obsidian-gateway
A filesystem- and git-native MCP gateway for Obsidian vaults. AI agents (Claude Code, Codex, Cursor, Antigravity) read, search, and edit a vault through git-aware, Obsidian-aware tools - with no Obsidian GUI running, and git as the single source of truth.
It exists because the Obsidian Local REST API plugin serves only the one vault open in a running desktop instance, writes without a lock (silent lost updates), requires a token in every client, and treats git as secondary. This gateway operates on the Markdown files directly, with git as the system of record.
Architecture
flowchart LR
subgraph clients [Agents]
A1[Claude Code]
A2[Codex]
A3[Antigravity / Cursor]
end
A1 --- M(( MCP ))
A2 --- M
A3 --- M
M -->|stdio, per repo, no auth| L[Local gateway]
M -->|HTTP + bearer + ACL| S[Shared gateway]
L --> V[/Vault: Markdown files/]
S --> V
V <-->|atomic write + scoped commit| G[(git)]
Both modes run the same tool implementation over the same path guards; they differ in transport, authentication/ACL, vault loading, and error masking.
Two ways to run
| Local mode (per repo) | Shared server (team) | |
|---|---|---|
| Use when | a repo wants its own vault for its agents | many people/vaults behind one always-on endpoint |
| Transport | stdio subprocess (launched by .mcp.json) |
HTTP (put behind Tailscale/HTTPS) |
| Secrets / tokens | none - nothing to generate | per-user bearer tokens (admin-generated) |
| Trust boundary | local filesystem access you already have | tailnet + HTTPS + per-vault ACL |
| Obsidian needed | no | no |
Most repos want Local mode. The shared server is only for a central, always-on team gateway.
Distribution - the stable branch ("update once")
The gateway ships from one moving branch, so a release reaches every consumer and server without re-pinning anything by hand.
flowchart LR
PR[merge PR to main] --> TAG[tag vX.Y.Z]
TAG --> MV[move stable -> vX.Y.Z]
MV --> C["Consumers<br/>uvx --refresh @stable<br/>(updates next session)"]
MV --> S["Servers<br/>daily uv tool reinstall<br/>(restart if stable moved)"]
- Consumers pin
@stablewithuvx --refresh-> the ref is re-fetched on every launch, so a new release auto-propagates the next time an agent starts. No per-repo re-pin. - Servers (long-running) run a pinned
uv tool install @stableplus a daily job that reinstalls + restarts only whenstableactually moves. - Every release is also an immutable
vX.Y.Ztag - pin a tag instead ofstablewhen you need a frozen, auditable version.
A moving tag does not work (uvx caches the resolved commit); a branch +
--refreshdoes.
Quickstart - local mode (zero secrets)
Add this to the repo's .mcp.json at the repo root:
{
"mcpServers": {
"wiki": {
"command": "uvx",
"args": ["--refresh", "--from", "git+https://github.com/fszalaj/obsidian-gateway@stable",
"obsidian-gateway", "--local"]
}
}
}
--localauto-detects the vault in the cwd, in order: the cwd itself if it has.obsidian/, then./wiki, then a single*-obsidian-vault/, then a single child dir with.obsidian/(ambiguous matches error). Pass--vault ./<dir>to be explicit.--refreshre-fetches@stableeach launch, so releases auto-apply (adds ~1-2s to start).- Commits are scoped to the vault's git subdir and attributed to your own
git config user.name/email. No token: the trust boundary is local filesystem access.
Open the repo in your agent, approve the wiki server once, done.
Tools
| Tool | |
|---|---|
list_vaults |
vaults reachable here |
list_notes |
Markdown paths in a vault |
read_note |
raw note content |
list_attachments / read_attachment |
list / read binary attachments (image -> inline Image, else File) |
list_canvases / read_canvas / write_canvas |
list / read / write Obsidian Canvas (nodes, groups, colors) |
search |
ripgrep literal/regex full-text |
backlinks |
notes that [[wikilink]] to a note |
list_tags |
inline #tags with counts |
query_notes |
find notes by frontmatter type / tag (headless Dataview-lite) |
write_note |
atomic write (+ optional commit) |
patch_note |
insert after a heading or at top/bottom, no full rewrite (+ commit) |
patch_frontmatter |
update YAML frontmatter keys, body intact (+ commit) |
delete_note |
delete a note (+ optional commit) |
rename_note |
rename/move + rewrite inbound flat [[wikilinks]] when the name changes (+ optional commit) |
git_status / git_commit |
pending changes / commit (subdir-scoped, attributed) |
Edits are atomic (temp file + rename). Every path goes through safe_note_path, which blocks
traversal, symlink escape, hidden/dotfiles, non-.md targets, and .git/.obsidian - a caller
can never read or write outside the vault's notes.
Shared server mode
Run this only for a central, always-on gateway reachable over the network.
1. Map vaults - cp vaults.example.yaml vaults.yaml, then set name -> path / repo_root / subdir. repo_root + subdir pathspec-scope commits to a vault that lives inside a larger repo.
2. Mint a token per user (the admin does this):
cp tokens.example.yaml tokens.yaml
openssl rand -hex 32 # once PER user -> the key
chmod 0600 tokens.yaml # refused at load if group/world-readable
tokens:
"8f3c…hex…":
sub: alice # identity recorded on that user's commits
vaults: [teamwiki] # the ONLY vaults this token may see/touch
write: true # false = read-only
A token sees only the vaults in its vaults list; anything else returns an opaque
vault_forbidden. vaults.yaml + tokens.yaml are gitignored.
3. Run - uv run obsidian-gateway (127.0.0.1:8765, path /mcp/). For a team box, run it as
a service behind Tailscale Serve - see deploy/ and Operate below.
4. Connect - the admin shares the token over a password manager (not chat):
claude mcp add --transport http --scope project teamwiki \
https://YOUR-HOST.<tailnet>.ts.net/mcp/ --header "Authorization: Bearer $GW_TOKEN"
Security model
- No secrets in the repo.
vaults.yaml/tokens.yamlare gitignored; only*.example.yamlship.tokens.yamlis refused at load if group/world-readable. - Local mode has no credential surface - a local stdio subprocess; the trust boundary is filesystem access the user already has.
- Server mode is defense in depth, not a public endpoint - tailnet ACL + HTTPS + per-user
StaticTokenVerifierbearer token + per-vault ACL. The bearer layer is a shared secret for use behind a trusted tailnet; do not expose the server publicly. - Path guards on all note I/O via
safe_note_path(traversal, symlink, hidden/dotfiles incl..env, non-.md,.git/.obsidian). Search/backlinks/tags are bounded to*.md. - Server-mode error masking - the HTTP server runs
mask_error_details=True: only the gateway's own expected failures surface asToolError; unexpected OS/git errors are hidden. Local mode keeps details visible. - Commits are attributed to the requesting user (server) or the local git identity (local), and pathspec-scoped to the vault subdir.
Set it up with an AI
Paste this into an agent at a repo's root to wire in local mode:
Add the obsidian-gateway to this repo so agents can read/edit our vault over MCP with zero
tokens:
1. Create or merge `.mcp.json` at the repo root with an mcpServers."wiki" entry that runs:
uvx --refresh --from git+https://github.com/fszalaj/obsidian-gateway@stable obsidian-gateway --local
(`--local` auto-detects the vault: ./wiki, a *-obsidian-vault dir, or a dir with .obsidian/.
If detection is ambiguous, use `--vault ./<vault dir>` instead of `--local`.)
2. Verify: `uvx --refresh --from git+https://github.com/fszalaj/obsidian-gateway@stable \
obsidian-gateway --help` resolves; then in the agent, call list_vaults and read one note.
Branch + PR, no direct push, no AI attribution.
For the shared server, ask your gateway admin for a token, then run the claude mcp add … from
Connect above.
Operate (servers)
A server runs the @stable release as a uv tool, with a daily job that reinstalls and
restarts only when stable moved. Reference units are in deploy/:
uv tool install --from git+https://github.com/fszalaj/obsidian-gateway@stable obsidian-gateway
# the binary lives in the uv cache, so point config at the live files via env:
# OBSIDIAN_GATEWAY_VAULTS=<dir>/vaults.yaml OBSIDIAN_GATEWAY_TOKENS=<dir>/tokens.yaml
deploy/obsidian-gateway.service- the service (systemd--user).deploy/obsidian-gateway-update.{service,timer}+deploy/auto-update.sh- the daily auto-update.
Update now instead of waiting for the timer: uv tool install --reinstall --from git+https://github.com/fszalaj/obsidian-gateway@stable obsidian-gateway, then restart the
service. Health: curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/mcp/ -> 401.
Release (maintainers)
- PR -> merge to
main(CI:uv lock --check, pytest matrix). - Bump
pyproject.tomlversion +CHANGELOG.md. - Tag
vX.Y.Zand push the tag (the release workflow builds it). - Move
stable:git branch -f stable vX.Y.Z && git push --force-with-lease origin stable.
Consumers pick it up next session; servers within a day (or restart now).
Develop
uv venv && uv pip install -e ".[dev]"
uv run pytest # ACL + path guards + edit/frontmatter + detect + masking
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file obsidian_gateway-0.5.1.tar.gz.
File metadata
- Download URL: obsidian_gateway-0.5.1.tar.gz
- Upload date:
- Size: 125.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
01b8b1c819550dc4462f82d29af6bcc351fd7ed9d826d531d0dc7612ee2a9c2e
|
|
| MD5 |
bac533001e60c2219357956dae82c483
|
|
| BLAKE2b-256 |
f0cbf4771022900b7ac6532d2c196618a4cdf1be536e8f33b515fdb593a4f88f
|
Provenance
The following attestation bundles were made for obsidian_gateway-0.5.1.tar.gz:
Publisher:
release.yml on fszalaj/obsidian-gateway
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obsidian_gateway-0.5.1.tar.gz -
Subject digest:
01b8b1c819550dc4462f82d29af6bcc351fd7ed9d826d531d0dc7612ee2a9c2e - Sigstore transparency entry: 1835972054
- Sigstore integration time:
-
Permalink:
fszalaj/obsidian-gateway@d80aa861ff3ebcab9168833824027f214c6b70c1 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/fszalaj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d80aa861ff3ebcab9168833824027f214c6b70c1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file obsidian_gateway-0.5.1-py3-none-any.whl.
File metadata
- Download URL: obsidian_gateway-0.5.1-py3-none-any.whl
- Upload date:
- Size: 28.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa282cc1d08a6ad9419fcdcb4ab6894a05c4c705742d7e8b2c0a4342e3b72709
|
|
| MD5 |
49e0faef5982e482bfca44457b1935ec
|
|
| BLAKE2b-256 |
d56dfb3f09723a49df6a202906573f08ddd1508554f8b0d8181bed3ec1b5dd21
|
Provenance
The following attestation bundles were made for obsidian_gateway-0.5.1-py3-none-any.whl:
Publisher:
release.yml on fszalaj/obsidian-gateway
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obsidian_gateway-0.5.1-py3-none-any.whl -
Subject digest:
fa282cc1d08a6ad9419fcdcb4ab6894a05c4c705742d7e8b2c0a4342e3b72709 - Sigstore transparency entry: 1835972211
- Sigstore integration time:
-
Permalink:
fszalaj/obsidian-gateway@d80aa861ff3ebcab9168833824027f214c6b70c1 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/fszalaj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d80aa861ff3ebcab9168833824027f214c6b70c1 -
Trigger Event:
push
-
Statement type: