Git Integration¶
Use this guide to choose and configure the right git mode for your deployment.
Modes¶
- Managed (
GIT_REPO_URL+GIT_TOKEN) The server owns clone, periodic pull, commit, and deferred push. - Unmanaged / commit-only (no
GIT_REPO_URL, existing git repo) The server commits writes (one commit per tool call), but never pulls or pushes. - No-git (default) The vault is treated as a plain directory with no git operations.
What a commit covers¶
In both git modes, the commit boundary is the MCP tool call, not the file.
A call that wrote one file commits under that file's path
(write: notes/one.md), so an ordinary vault's log reads as it always
did. A call that touched many files, such as move_folder, rename with
update_links=True, or okf_convert_links, produces one commit with the
subject <tool>: N files, where a per-file boundary produced one commit per
file (#1264).
No setting changes this. In managed mode, the deferred push sends
whatever commits accumulated during the idle delay, however many calls
produced them.
Getting an overwritten note back¶
On a git-backed vault, a write that replaces an existing note returns
previous_revision: the commit holding the content it replaced. Pass that
SHA to read as revision= and the replaced content comes back, ready to
write again:
read(path) → the pre-write etag
write(path, content=..., if_match=<pre-write etag>) → previous_revision: 9f2c1ab
read(path, revision="9f2c1ab") → the replaced content
read(path) → the current etag
write(path, content=<replaced content>, if_match=<current etag>)
Overwrite protection is enabled by default. Each replacement uses the etag from a current read of its target; a historical read supplies the old content, not an etag for replacing the current file.
A SHA from get_history works the same way, which is also how a note
deleted since is recovered: read it at a revision that still has it. Pass
the name the note has today; a rename since that revision is followed.
Two limits. previous_revision is absent when no commit provably holds the
replaced content: a newly created note, or two writes in quick succession
where the first never reached a commit. Content that never reached a commit
is not recoverable from git at all. Separately, read fails rather than
returning content where git's records do not connect the revision to
today's note, because the next thing a caller does with the result is write
it back. The full rules, including the cases that fail, are in
Reading an earlier revision.
Managed Mode (Recommended For Containerized Deployments)¶
Use managed mode when the server should fully own git synchronization.
MARKDOWN_VAULT_MCP_SOURCE_DIR=/data/vault
MARKDOWN_VAULT_MCP_READ_ONLY=false
MARKDOWN_VAULT_MCP_GIT_REPO_URL=https://github.com/your-org/your-vault.git
MARKDOWN_VAULT_MCP_GIT_USERNAME=x-access-token
MARKDOWN_VAULT_MCP_GIT_TOKEN=github_pat_xxx
MARKDOWN_VAULT_MCP_GIT_PULL_INTERVAL_S=600
MARKDOWN_VAULT_MCP_GIT_PUSH_DELAY_S=30
Behavior:
- If
SOURCE_DIRis empty at startup, the server clonesGIT_REPO_URLinto it. - If
SOURCE_DIRis already a git repo, the server verifiesoriginmatchesGIT_REPO_URL. - Each write tool call is committed (see What a commit covers); the accumulated commits are pushed after the configured idle delay.
What the remote must permit¶
GIT_USERNAME and GIT_TOKEN decide who the server authenticates as. They
say nothing about what that identity is allowed to do, and a token that
authenticates perfectly can still be refused at push time. Both conditions
below are ordinary first-run states rather than mistakes, and neither
is visible from the credentials.
The token needs push access to the branch the vault tracks. A read-only token clones and pulls without complaint, so a vault can run for hours, committing locally, before anything reveals that nothing is replicating.
A protected default branch refuses the push. GitLab protected branches, GitHub branch protection, and GitHub rulesets all reject a push that does not meet their conditions, whatever the token's own scopes say. Either grant the token's identity permission to push to that branch, or point the vault at a branch it may write:
- GitLab: Settings → Repository → Protected branches, or use a token whose role is listed under Allowed to push and merge.
- GitHub: Settings → Branches (or Rules → Rulesets); a bypass entry for the app or account behind the token.
The symptom is the same either way: writes succeed, read serves them back,
and the commits accumulate in the clone without reaching the remote. See
When the clone stops reaching its remote
for what that looks like, and
Manual sync for the one call that returns the
remote's actual refusal message.
- Periodic pull uses fast-forward-only updates.
Two mechanisms sit alongside the periodic loop, both described below: a
push webhook that pulls the moment someone pushes, and the git_sync
tool for pulling or pushing on demand from inside a conversation.
Push-Triggered Pull: Webhooks¶
The periodic loop leaves reads up to GIT_PULL_INTERVAL_S seconds behind
the remote (default 600). In a multi-author vault, where a teammate or
another instance commits from elsewhere, that window is what a webhook
closes: the host delivers a push event, the server pulls and reindexes
straight away, and staleness drops to delivery latency, a couple of
seconds in practice.
GitHub and GitLab each get their own endpoint, mounted only when that host's credentials are set. Both endpoints run the same pull-and-reindex path and differ only in how a delivery proves it came from the host. Under stdio no HTTP server exists, so nothing is mounted and the settings have no effect.
GitHub¶
Generate a secret and set it:
MARKDOWN_VAULT_MCP_GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32)
Setting the secret mounts POST /github-webhook. In the GitHub repository,
add a webhook pointing at https://<your-host>/github-webhook with content
type application/json, the same secret, and the push event selected.
Every delivery's X-Hub-Signature-256 header is verified: HMAC-SHA256 over
the raw body, compared in constant time.
GitLab¶
GitLab authenticates webhooks two ways, and the version decides which one is available.
Signing token, GitLab 19.0 and later. The stronger form, and the one to
prefer. GitLab generates this one; you do not invent it. In the webhook
form select Generate signing token, then copy the value it shows, which
starts with whsec_ and is displayed only once:
MARKDOWN_VAULT_MCP_GITLAB_WEBHOOK_SIGNING_TOKEN=whsec_...
GitLab signs each delivery with HMAC-SHA256 over the webhook id, the timestamp and the raw body, following the Standard Webhooks specification. The server checks the signature and rejects any delivery whose timestamp is more than five minutes from the current time, so a captured delivery cannot be replayed later.
A self-chosen string authenticates nothing here: the part after whsec_ is
the base64-encoded key GitLab signs with, so the server decodes it before
checking a signature. A value that is not base64 is refused at startup, and
one without the prefix logs a warning.
Secret token, any version. The original form, and the only one below 19.0. This one you do choose, and enter in the webhook form's Secret token field:
MARKDOWN_VAULT_MCP_GITLAB_WEBHOOK_SECRET_TOKEN=$(openssl rand -hex 32)
GitLab sends this value in clear text in the X-Gitlab-Token header. It
proves nothing about the body and never expires, so GitLab no longer
recommends it for new webhooks. Reach for it when the GitLab version
predates the signing token; the server logs a warning at startup when it is
the only credential set.
Either setting mounts POST /gitlab-webhook. In the GitLab project, go to
Settings > Webhooks, select Add new webhook, point the URL at
https://<your-host>/gitlab-webhook, fill in the matching token field, and
select the Push events trigger.
Setting both accepts either form, which is what makes a migration possible: add the signing token here, switch the webhook over in GitLab, then drop the secret token once deliveries are landing.
GitLab has no handshake event. Its Test button sends a real Push Hook,
so a test delivery pulls exactly as a push does.
What both endpoints do¶
- An invalid or missing credential returns 401 and no git operation runs.
- A push event pulls first, then reindexes only when HEAD actually moved. A push to a branch the vault does not track leaves HEAD where it was, so it costs a fetch and nothing more.
ping, GitHub's handshake delivery, answerspong; every other event returns 200 and does nothing.- A delivery whose pull did not apply returns 503, so the host retries it
instead of marking it delivered. A pull that keeps failing, such as an
unresolved conflict, exhausts the retries and waits for the next
periodic tick. Divergent history is not a failure: it flows through the
Syncthing-style sibling resolution described under
git_syncbelow. - A delivery to a server with no managed remote returns 200, not 503. No remote exists to pull from and a retry cannot change that, so the delivery is recorded rather than retried. Each one logs a warning naming the problem, and the server logs the same warning once at startup.
- A delivery arriving while the initial index build is still running is handled, not dropped. The pull is a pure git operation and runs regardless of index state; only the reindex is skipped, and the boot reconciliation pass that follows the build picks the pulled changes up.
Managed mode only
Set these only where the server owns the remote. Outside managed mode the endpoint is inert: it verifies signatures and answers 200, but there is no remote to pull from, so every delivery is a no-op. The server says so at startup and on each delivery, because a webhook that is quietly doing nothing looks the same as one that is working.
This is a no-op rather than a failure as of 4.1. Before that the pull
path ignored the sync switch unmanaged mode turns off and ran
git fetch origin anyway: a checkout with no reachable origin
failed that fetch and answered 503, burning the host's retries on every
push, and against a vault that was not a git repository at all the
pull raised out of the handler.
Keep GIT_PULL_INTERVAL_S enabled. The webhook narrows the staleness
window; the loop is what catches the deliveries the webhook loses.
The file watcher steps aside
A webhook credential on an HTTP or SSE transport disables the filesystem
watcher, the same way GIT_PULL_INTERVAL_S > 0 does. Git rewrites the
working tree during a checkout, and a watcher firing mid-checkout would
scan a partial tree. Reindexing stays driven by the webhook and the
periodic loop. See File Watcher.
The endpoints exist only on those transports. Under --transport stdio
the credential mounts no route, so the watcher stays on and the server
logs a webhook_transport_inert warning at startup: nothing can deliver
to that deployment, and the watcher is what keeps external edits
visible.
The variables themselves are listed under Change detection in the configuration reference.
Manual sync: git_sync tool¶
The periodic loops are time-based: pull every
MARKDOWN_VAULT_MCP_GIT_PULL_INTERVAL_S seconds (default 600), push
MARKDOWN_VAULT_MCP_GIT_PUSH_DELAY_S seconds after the last write
(default 30). For workflows where the LLM needs to confirm "your changes
are now on the remote" before telling the user to check another device,
or wants to pull in remote edits right now before continuing the
conversation, call git_sync directly:
git_sync(direction="both")
Use direction="pull" or direction="push" to skip a leg. In
direction="both" mode the push leg only runs when the pull leg
succeeded; otherwise push stays null and the LLM should inspect
pull.reason (and pull.conflict_files) before retrying.
git_sync(direction="push") is how you see why a push was refused. It
runs a real push and returns the remote's own words in push.hint:
remote: GitLab: You are not allowed to push code to protected branches on this project.
That is the fastest route from "the vault is not replicating" to the sentence that names the cause, and it is worth reaching for before inspecting the container or reproducing the push by hand.
Do not substitute git push --dry-run for it. A dry-run push negotiates with
the remote but never runs its pre-receive hooks, which is where a protected
branch does its rejecting, so it reports success against a remote that would
refuse the real push. This is also why a dry_run=true git_sync returns
applied=false with reason="dry_run_unsupported" on the push leg rather
than a prediction: there is no honest local answer to give.
dry_run=true previews what a pull would do (useful for "is there
anything new on origin?") without risking an in-conversation conflict.
The preview classifies the clone against the remote before predicting. A
history that has moved on both locally and on the remote reports
fast_forward=false with reason="diverged", which is the signal that the
real pull would rebase and may end in sibling resolution or fail outright.
A clone with nothing to pull (up to date, or carrying only unpushed local
commits) reports would_apply=false. The push leg has no safe local
"would this be accepted" probe, so a dry-run push always returns
applied=false with reason="dry_run_unsupported".
Conflict outcome: Syncthing-style sibling resolution
When the pull would otherwise need an interactive merge, the server follows the #232 Syncthing-style flow:
- The pull succeeds (
pull.applied=true,pull.reason="conflicts_resolved_with_siblings"). - HEAD advances to the remote tip, so the canonical path now reflects the remote (remote wins).
- The local versions that conflicted are preserved as
<basename>.conflict-mcp-<timestamp>.mdsiblings on the same path; their vault-relative paths are listed inpull.conflict_files. pull.commits_pulledis0on this path because the rebase replays your local commits on top of the remote tip — the counting model only reports linear-history catch-ups.
The LLM (or a downstream agent) is expected to read the listed
sibling(s), reconcile the local content against the remote, and
delete the sibling once merged.
Writes landing during a pull
A write whose deferred git commit has not yet run when a pull starts is
never lost. Before every real (non-dry-run) pull (periodic or git_sync),
the server pauses new writes and drains the deferred-commit queue (a
dry_run preview only fetches and never quiesces), so in the normal case the
just-written file is committed first and the merge runs on a clean tree
(#571). If
that write and the remote touched the same file, it flows through the
Syncthing-style sibling resolution above rather than failing. The drain is
best-effort and time-bounded: if it cannot finish in time, the pull logs a
warning and proceeds anyway; the write is still safely on disk and is
committed on the next opportunity, at worst reverting to the pre-#571
behavior (a non-fast-forward push that the next reconcile resolves).
The full enumeration of pull.reason and push.reason values lives in
the git_sync tool reference.
git_sync is hidden when the deployment isn't in managed git mode (no
MARKDOWN_VAULT_MCP_GIT_REPO_URL set) or when
MARKDOWN_VAULT_MCP_READ_ONLY=true.
When the clone stops reaching its remote¶
A clone can end up unable to send its commits: another writer pushed first
and the rejection stands, or the histories diverged in a way the conflict
resolver gave up on. Writes keep working (the commit lands, read serves it
back), but nothing reaches the remote.
Two things make that visible.
Every write tool says so. While the clone is not reaching its remote,
each write result carries a remote object with state, reason, since,
and a detail sentence for the caller to act on. See
the write-tools reference for the shape
and its limits. This is the signal for a client whose only route to the
repository is this server: it says the content is committed locally only, so
the client can keep its own copy instead of treating the note as saved.
The log records the transition, not the cycle. Entering that state logs
once at ERROR:
ERROR markdown_vault_mcp.git.health: git_remote_unsynced kind=push reason=non_fast_forward ...
That line carries the cause alongside the reason code, because push_failed
is the bucket every unrecognised git message lands in and names a state
rather than a problem. Recovery logs once at INFO
(git_remote_resynced), carrying when the outage started.
The attempts are visible too, at the default level. A rejected push logs
at WARNING with the git stderr that says why, on the deferred and the
startup path alike, as does a fetch that could not reach the remote:
WARNING markdown_vault_mcp.git.push_scheduler: git_push_failed cmd=... returncode=1 stderr=remote: GitLab: You are not allowed to push code to protected branches on this project.
Credentials are redacted. These lines were at DEBUG until
#1330, which
meant a deployment running at INFO saw the transition and nothing else.
Because every retry was equally silent, it could not tell a clone that was
still retrying from one that had stopped. The level follows what caused the
attempt: a push that a write, a git_sync flush, or startup caused warns,
so the line repeats once per burst of writes and is the evidence the
retries are happening. The pull loop's own retry of a still-pending push
fires on its timer, and that one stays at DEBUG (a warning per tick,
indefinitely, is the line #1287 removed), as do the per-cycle details of a
divergence the resolver is working through. A pull that git_sync caused is
the exception on that side too: when its rebase stops, git's words log at
WARNING. Whichever entry point ran the pull, those words reach the
transition line as its cause=, so a rebase that stopped for something
other than a conflict is named once at ERROR. In
#1362 that
something was a missing committer identity, and the only trace had been a
line about a conflict-resolution loop. Two kinds of failure stay loud
for their own reasons: an unexpected
exception on the push or resolve path, and a failure that can leave the
working tree inconsistent (a rebase that would not abort, an upstream file
that would not restore).
Alert on the transition lines rather than the per-attempt ones: a warning every sync cycle is easy to scroll past, which is how the incident behind #1287 ran for hours before anyone noticed.
Both signals clear on their own once a push succeeds, or once a pull reconciles the divergence that raised them. A successful pull does not clear a failed push: reading from the remote is no evidence that anything reached it.
Unmanaged / Commit-Only Mode¶
Use unmanaged mode when another process controls pull/push, but you still want MCP writes committed locally.
MARKDOWN_VAULT_MCP_SOURCE_DIR=/data/vault
MARKDOWN_VAULT_MCP_READ_ONLY=false
# No GIT_REPO_URL
# No GIT_TOKEN required
MARKDOWN_VAULT_MCP_GIT_COMMIT_NAME=markdown-vault-mcp
MARKDOWN_VAULT_MCP_GIT_COMMIT_EMAIL=noreply@markdown-vault-mcp
Behavior:
- If
SOURCE_DIRis a git repo, writes are committed locally. - No periodic pull.
- No push.
No-Git Mode¶
Use no-git mode when you only need file persistence.
MARKDOWN_VAULT_MCP_SOURCE_DIR=/data/vault
MARKDOWN_VAULT_MCP_READ_ONLY=false
# No git env vars required
Behavior:
- Files are written to disk.
- No staging, commits, pulls, or pushes.
Provider Username Reference¶
MARKDOWN_VAULT_MCP_GIT_USERNAME controls the HTTPS username prompt:
- GitHub:
x-access-token - GitLab:
oauth2 - Bitbucket: account username
Git LFS¶
If your vault tracks large files (PDFs, images) with Git LFS, the server runs git lfs pull on startup to resolve LFS pointers into actual file content. This is enabled by default.
Set MARKDOWN_VAULT_MCP_GIT_LFS=false to skip the LFS pull. Use this when:
- Your vault does not use Git LFS
git-lfsis not installed in your environment- You want faster startup and don't need LFS-tracked attachments
A revision read does not go through LFS: read(revision=) of a note that
git stored in LFS at that revision fails rather than returning the pointer
text. See Getting an overwritten note back.
MARKDOWN_VAULT_MCP_GIT_LFS=false
Legacy Compatibility¶
GIT_TOKEN without GIT_REPO_URL still works for backward compatibility and logs a deprecation warning.