4.2¶
4.2 makes git-backed vaults safer to trust and draws a clearer boundary around
OKF. An agent can undo an overwrite from the client: an overwriting write
names the commit holding what it replaced, and read accepts a revision to
get it back. Writes commit once per tool call instead of once per file, each
commit is scoped so an operator's staged work is never swept in, and every
write reports when the clone cannot reach its remote. The OKF stabilization
makes audits and parsing resilient, keeps migration transforms mechanical,
corrects navigation guidance, and documents the limits of current upkeep and
export. A greenfield north star now separates portable author claims from
server observations and operator authority; its seven-stage roadmap describes
future work. GitLab-hosted vaults gain push-triggered reindexing,
Voyage vaults get better search relevance, and server instructions arrive in
full on Claude Code. Seven link-extraction fixes reshape the link graph.
Nothing breaks: no env var, tool, or library import changes meaning. Two
one-time costs on first start after upgrade: every vault rebuilds its text
index once, and a Voyage vault re-embeds once.
This is the first release since 4.1. Most of it happened in one place, the git integration, pushed by two outside reports: a vault where one bulk call produced 2,596 commits, and a production deployment that wrote for hours into a clone that could no longer push. Around that sit a GitLab webhook and better search on Voyage vaults, plus a fix for server instructions that never fully arrived.
OKF stabilization and its next direction¶
The work to stabilize OKF exposed a deeper design problem. The server had treated storage as authorship, a changed file as a verdict on verification, a reserved filename as permission to replace content, and search admission as a rule about what belongs in a bundle. Repeated review made individual mechanisms more consistent without making those responsibilities equivalent (#1433 review record, #1441).
The project has adopted a greenfield north star for future OKF work: people and agents should be able to exchange ordinary files while keeping author claims, server observations, and operator-granted maintenance authority distinct. A seven-stage roadmap covers bundle inventory, preservation, evidence applicability, deliberate authoring and review, navigation and editorial history, provenance-aware retrieval, and faithful publication (#1425, #1450).
Those capabilities have not shipped. The stages are independently shippable, with no promised release or date. Version 4.2 keeps the current OKF model and sets a narrower stabilization boundary: restore existing contracts, make parser and audit paths resilient, keep mechanical transforms mechanical, and state current operating limits accurately. The OKF fixes under Search and indexing are that stabilization work; later roadmap slices will change the underlying model.
Undo an overwrite without leaving the client¶
On a git write-through vault, an overwritten note's previous content was always safe in history, but not reachable over MCP. As the motivating issue put it: "In practice 'git is the recovery' means a human with shell access to the checkout, not the agent" (#1137, #1288).
Two additive pieces close that gap. An overwriting write now returns
previous_revision, the commit that holds the content it replaced. And
read accepts a revision argument (that SHA, or any commit from
get_history), returning the note as it stood then. The full round trip:
write(path, content=...) → previous_revision: 9f2c1ab
read(path, revision="9f2c1ab") → the replaced content
read(path) → the current etag
write(path, content=<what you read>, if_match=<that etag>)
The content comes back as the whole raw file, frontmatter included, so the last step restores the note byte for byte. A note that has since been deleted is still readable at a revision that has it; that is how a deleted note is recovered.
One design decision worth understanding: a revision read resolves the
note, not the path. You pass the name the note has today; a rename since
that revision is followed, and the response reports the historical_path it
carried then. Where git's records cannot connect today's note to that
revision (a different note took over the name, a rename rewrote the note
beyond recognition, the revision is off the current history), the read
fails rather than returning content, because the next thing a caller
does with the result is write it back. The
tools reference lists every
refusal case. Every refusal, including the one where git records the note
as a copy of another, ends by pointing at get_history; none suggests
reading a different note, because the copy source is only git's similarity
match. In the same spirit, previous_revision is absent whenever no
commit provably holds the replaced content, such as a brand-new note or a
vault without git.
Requires a git-backed vault; revision reads cover .md notes and do not go
through LFS. No restore tool ships with this, deliberately: the read/write
round trip above is the restore, and it keeps the caller in charge of what
gets written. See "Getting an overwritten note back" in the
git integration guide.
History that describes the right note¶
Building the revision reader forced a hard look at what the history readers were actually reporting, and six long-standing bugs fell out. Every one of them was reachable on 4.1 and earlier:
- A note whose name contains glob characters got another note's history: a
vault holding
b1.mdandb[1].mdreportedb1.md's commits under both names. Note names are now passed to git as literal paths everywhere (#1303, #1305). - A note that reused a freed name inherited its predecessor's commits, and
a
get_diffspanning the two rendered one note's content against the other's as though it were one file being edited (#1285, #1298). - A rename that also rewrote the note heavily dropped its pre-rename commits: git scored the rename below its 50% default similarity, while the project's other walks accept 30%. Every reader now follows renames at the same 30% threshold (#1297, #1302).
- A note renamed while resolving a merge had no history at all:
get_historyreturned an empty list for a note the server could read (#1306, #1314). - On a SHA-256 repository,
get_diffrejected the commit IDsget_historyhad just returned: the ID check stopped at 40 hex digits, and a SHA-256 object ID has 64 (#1284, #1286). - Non-ASCII note paths came back octal-escaped (
"caf\303\251.md"), and a per-commit diff of such a note came back empty (#1282, #1291).
The end state is uniform: get_history, get_diff, and the new
read(revision=) all identify a note the same way, with the same rename
following, the same threshold, and the same lineage boundary at the note's
creation.
One commit per tool call¶
This one arrived from the field.
@mikebronner ran a single
okf_convert_links call on a 3,918-document vault and got 2,596 commits,
one per file: "One logical migration is 2,596 entries, so git log no
longer answers 'what changed and why' at the level a reader asks it"
(#1264).
They also contributed the fix
(#1265).
The commit boundary is now the MCP tool call. A bulk operation such as
okf_convert_links, move_folder, or rename with link updates produces
one commit with the subject <tool>: N files. A call that wrote a single
file keeps the familiar write: note.md subject, so an ordinary vault's log
reads exactly as it always has. No mode knob exists; the boundary is
unconditional.
The same work fixed how much a commit takes with it. The auto-commit used to
commit the whole index, which had a consequence for anyone sharing the
checkout with the server: an operator's deliberately staged-but-uncommitted
work could disappear into a commit attributed to a vault write they never
made
(#1249,
#1273,
#1304).
Commits are now scoped to the operation's own paths: every git command
involved, from staging through the no-op check to the commit itself, names
exactly the files the operation touched and nothing else. A write during an operator's in-progress merge now commits
nothing and logs why, leaving the merge intact. And move_folder now
reports every file it moves to git, including files outside the attachment
allowlist, which previously left the working tree dirty
(#1238,
#1244).
Commit identity got the same treatment. On an OIDC deployment,
MARKDOWN_VAULT_MCP_GIT_COMMIT_NAME_CLAIM and _EMAIL_CLAIM promised to
stamp commits with the authenticated user, but the claims were resolved on a
background thread that had no request context, so every commit silently fell
back to the static server identity
(#1218,
#1226). The
claims are now resolved when the tool call arrives and carried to the
background commit, so the commit author is the person who made the write.
It also now agrees with the OKF provenance record for the same write, which
was already stamped correctly.
The same claims also fall back silently when the identity provider does
not put them in the access token at all. That outcome looked exactly like a
deployment that had never configured attribution (the only trace was a
DEBUG line), and the one nearby startup warning is suppressed precisely
when a claim is configured
(#1331).
The first authenticated write now reports the configured key and whether
it was absent or empty, once per field per process; the commit still falls
back to the static identity. See "What a commit covers" in the
git integration guide.
A write that cannot leave the host says so¶
The sharpest report of the cycle came from a production deployment. @FoundationOperations ran the server as the only git route for their agents, and a diverged clone stopped pushing: "the tools reported durable writes for changes that could never leave the host. This ran for hours before anyone noticed. Three commits accumulated locally; one of them was an agent's only copy of its work" (#1287).
Every vault-mutating tool result now carries a remote object while the
clone is not reaching its remote
(#1300):
{"state": "unsynced", "reason": "non_fast_forward",
"since": "2026-09-04T07:12:33+00:00",
"detail": "This vault's git clone has not reached its remote since ..."}
The write still succeeded and the commit is real; the key tells the caller
it is committed locally only, so an agent knows to keep its own copy and
alert its user. When there is nothing to report, the key is absent rather
than null.
The log marks the transition once (git_remote_unsynced at ERROR,
carrying git's own words as cause=, and git_remote_resynced on
recovery). A push that a write, a git_sync flush, or startup caused logs
its rejection once at WARNING with the redacted git stderr, so the line
repeats once per burst of writes and shows the retries are happening; the
pull loop's timer-driven retry of a still-pending push stays at DEBUG, as
does the per-cycle detail of a divergence the resolver is working through
(#1330).
Writes are warned, never refused; the same report asked for a
configurable block, which is tracked separately as
#1299.
The same incident exposed a lie in git_sync: a dry-run pull "reported
fast_forward: true, would_apply: true for the same divergence that the
non-dry-run call refused" (the reporter's words in
#1287,
split out as
#1292). The
pull leg now classifies the clone against the remote before predicting
anything: a divergence reports fast_forward=false with
reason="diverged", and a clone that is merely ahead of its remote stops
reporting the remote tip as its projected target, which had been triggering
pointless full reindexes through the sync paths
(#1301).
The pull side had a blind spot of its own, found and fixed by the same
deployment. When another writer pushed while the instance held local commits, the
clone "stops syncing" while every write and edit "returns success" and
the log showed a conflict-resolution loop that "exceeded 50 iterations" (the
reporter's words in
#1362).
There was no conflict: the pull pipeline's git rebase ran with no
committer identity, which the published container does not have, so it
stopped at the first replayed commit with Committer identity unknown and
nothing half-merged for the resolver to act on. The rebase now runs under the
configured commit identity, the same one every per-write commit uses, and
the transition line's cause= carries the rebase's stderr on the pull side
as it already did for pushes. Contributed by
@FoundationOperations
(#1363).
See "When the clone stops reaching its remote" in the git integration guide.
Push-triggered reindex for GitLab-hosted vaults¶
At 4.1 the push webhook was GitHub-only end to end, so "on GitLab there is no configuration that gets external edits indexed promptly": the vault was stuck with the periodic pull loop, 600 seconds by default (#1178).
A GitLab-hosted vault now reaches the same staleness window as a GitHub one
(#1262): a
push lands, GitLab delivers a Push Hook to POST /gitlab-webhook, and the
server pulls and reindexes within a couple of seconds. Two credentials are
supported, either of which mounts the route:
MARKDOWN_VAULT_MCP_GITLAB_WEBHOOK_SIGNING_TOKEN: the value GitLab generates in the webhook form (whsec_...), verified as a Standard Webhooks signature. Needs GitLab 19.0 or later.MARKDOWN_VAULT_MCP_GITLAB_WEBHOOK_SECRET_TOKEN: the legacy plain token, accepted on any GitLab version, sent in clear by GitLab; startup warns when it is the only credential.
Set both during a migration and the server accepts either.
MARKDOWN_VAULT_MCP_GITHUB_WEBHOOK_SECRET and /github-webhook are
untouched; an existing GitHub setup needs no change. The setup walkthrough
is in the git integration guide.
One related fix reaches beyond GitLab: on --transport stdio, where no HTTP
route can exist, a configured webhook credential used to stand the file
watcher down anyway, leaving that deployment with no external-change
detection at all. That hole existed at 4.1 for
GITHUB_WEBHOOK_SECRET
(#1263,
#1277). The
watcher now stays on, and startup logs webhook_transport_inert naming the
credential that does nothing on that transport.
Server instructions now arrive in full¶
At 4.1, this server's composed instructions were 2,358 characters, and
Claude Code caps a server's instructions at 2,048 UTF-16 units. The final
310 characters were silently cut, and they were the ones that mattered
most: the READ-ONLY/READ-WRITE announcement, and the position where an
operator's MARKDOWN_VAULT_MCP_INSTRUCTIONS_EXTRA is appended
(#1252).
Worse, with several instances of this server connected, the one line
guaranteed to survive was the identity line, a constant, byte-identical
across all of them.
Instructions are now composed by semantic role (identity, routing, instance mode, operator policy, then capabilities and workflows), so the parts that distinguish and govern a deployment come first and fit the cap (#1255, #1258, built on the same change in fastmcp-pvl-core 6). Concretely, for an operator:
- The identity line now names your deployment (
work-vault: ...) instead of a shared constant, and a newMARKDOWN_VAULT_MCP_INSTANCE_DESCRIPTIONvariable carries a one-line description of what this instance holds, the thing a client needs to route between several connected vaults. The upstream migration guidance: move descriptions of the material there, and keep behavioral rules inMARKDOWN_VAULT_MCP_INSTRUCTIONS_EXTRA. INSTRUCTIONS_EXTRAitself moved from the end of the text, where Claude Code was discarding it, to the operator-policy slot near the front.- The legacy
MARKDOWN_VAULT_MCP_INSTRUCTIONSfull replacement still wins, but is deprecated and logs a warning naming the two variables it ignores. - Generated guidance targets 1,536 units, reserving the rest of the 2,048 for the operator's routing and policy text. Crossing either threshold logs a startup warning; the server never truncates.
The whole client-facing surface (instructions, tool descriptions, schemas) is now also measured and budgeted in CI (#1253), so it cannot quietly grow past what clients tolerate again.
Search and indexing¶
Voyage vaults search better with nothing to configure. Voyage's
embedding models are asymmetric: they expect queries and documents to be
embedded differently, and "embedding both sides identically leaves
retrieval quality on the table for these models; the asymmetry is the
vendor's own recommended usage"
(#1135).
Notes are now embedded as documents and search queries as queries on the
voyage provider
(#1270). The
other providers send byte-identical requests to before. An existing Voyage
vault re-embeds itself once on first start after upgrade, automatically, at
the cost of one pass of Voyage API calls; see the
embeddings guide.
An unparseable note no longer serves stale content. The index used to
encode "deleted," "excluded," and "could not be parsed" as the same
absence, and the pipelines disagreed on what to do when a previously
indexed note stopped parsing; one of them kept serving its last-good
content
(#1129,
#1227). Skips
are now recorded first-class: a note that becomes unparseable drops out of
search and read on the pass that detects it, and shows up in
get_index_status.skipped_files until it is fixed or deleted. This is the
change behind the one-time index rebuild described under Upgrading.
stats stops miscounting OKF bundles. On a fully conformant OKF vault,
stats reported untyped notes while okf_validate reported zero findings;
both were counting the same reserved index.md files, which the spec
exempts from typing
(#1251,
#1267). The
histograms now cover the same note population okf_validate audits, and
reserved files are reported separately as reserved_count.
OKF staleness, the single-verifier shorthand and the provenance stamps
now follow the spec. OKF v0.2 says a note is stale from its stale_after
on, that consumers "must treat a bare mapping as a one-element list" for
verified, and that generated.at and verified[].at are "an ISO 8601
datetime"; the spec was amended in place on 2026-08-21 to make every such
timestamp one "with an explicit UTC offset" and stale_after "an absolute
instant"
(#1357,
#1373,
#1372).
The server departed on all three. A note was reported current on its
stale_after date, and a quoted instant ('2026-01-01T00:00:00Z') was
ignored altogether. A verified written as a single mapping read as
unverified, and okf_verify on such a note replaced that mapping with its
own entry, losing the earlier attestation. And both stamps were bare dates,
which a consumer strict about offsets rejects. Now both stale_after
spellings work: a bare date (2026-12-31) is stale from that server-local
day on, and an instant (2026-12-31T00:00:00Z, quoted or not) is stale
from that moment on, with its offset respected; a time without an offset is
allowed by neither text of the spec and is ignored. The stale flag, the
stale filter, the stats stale count and ranking all agree. A bare
verified mapping is one entry for the trust tier, the okf_verify append
and its verified_count. And every stamp the server writes is a UTC instant
in the spec's example form, 2026-09-07T05:32:29Z, written as a string so
the YAML emitter cannot re-spell it; stamps already on disk are left as they
are until the note is next written.
A maintained log.md stops collecting frontmatter blocks. With
OKF_WRITE on and a required_frontmatter configured, every write into a
folder added one more identical ---\ntitle: Log\n--- block to the top of
that folder's log.md; one live folder had accumulated eighteen
(#1391). The
per-write log append reads the file, adds its bullet and writes it back with
the frontmatter carried across. But the read hands back the whole file, block
included, and the write puts the carried block above whatever body it is
given. The append now takes the opening block off the text first, so the file
keeps exactly one however many writes the folder receives. Nothing reported
the defect because the bullets were always correct and the file stayed
indexed throughout. Blocks a 4.1.0 or 4.2.0-rc.0 vault already wrote stay
where they are, in the body: removing them means telling a stacked block
apart from one a log quotes on purpose, and that is
#1403.
okf_validate reports frontmatter on index files. OKF allows an
index.md no frontmatter except an okf_version declaration on the bundle
root, and the audit never checked: a root index carrying the
conflict_with keys a git conflict writes, or a folder index with a
hand-written title, still audited clean
(#1396). A
new conformance finding, index_frontmatter, lists them. An empty block
counts, as does YAML that does not parse, and so do index files the server
generated. With
MARKDOWN_VAULT_MCP_REQUIRED_FIELDS set, the server adds the required
fields to each index it generates, which keeps the file in search. The
audit reports those fields like any others. Leave them in place. Indexing
is unchanged. The conformance ratio still counts notes only.
A note that opens with a brace no longer takes down the audit. The
frontmatter library picks its parser from the block's opening delimiter, so a
file starting with { is read as JSON. When that JSON was malformed, the
error it raised was not the one every caller guarded against, and it escaped
(#1408):
okf_validate returned no report at all for the whole vault, naming no path,
and read on such a file raised instead of returning nothing. Both now treat
it as what it is. The audit lists the file under unparseable_frontmatter
and reports the rest of the bundle; read returns nothing and logs a
warning; the indexer records the file as a parse_error skip rather than an
internal_error one, so get_index_status.skipped_files names the real
cause. A well-formed JSON block still parses as it always did.
The agent is no longer told to maintain navigation the server maintains.
On any OKF-active vault the server's instructions asked the agent to update
log.md and index.md when it edited. With
MARKDOWN_VAULT_MCP_OKF_WRITE on, the server regenerates a written folder's
index.md itself, so an agent that followed the instruction lost its edit at
the next write into that folder, with nothing reporting it
(#1431). The
sentence now depends on who is doing the upkeep: with the layer on the agent
is told the server keeps those files current and to leave them alone, and
without it the original advice stands. The OKF guide carries the same
correction, along with what that means for a shared vault, and it no longer
claims a hand-edited index.md survives.
Two other guide corrections ride along. Saving a note into a folder produces
one commit naming the note, its log.md and its index.md, not one commit
per file, which the guide and the design notes still described from before the
per-tool-call commit boundary
(#1423). And
the enforcement, upkeep and export sections now state their limits rather
than reading as guarantees
(#1449): what
the provenance stamp does and does not evidence, which write paths keep
navigation current and which leave it stale, that export packages what the
index holds without validating the result, and what disabling the layer does
not repair.
A migration transform stays mechanical for library callers too. With
MARKDOWN_VAULT_MCP_OKF_WRITE on, the one-shot transforms
(okf_convert_links, okf_generate_index, okf_seed_log) are exempt from
provenance stamping: a mechanical rewrite is not authorship. The exemption
was entered in the MCP tool handlers only, so the same call through the
Python API stamped generated into the files it rewrote. Because generated
reserved files keep their existing keys, that stamp then survived every later
regeneration
(#1401). Each
transform now suppresses the stamp on every write it issues, so both entry
points agree. Existing frontmatter, including a note's own generated and
verified, is left alone. Stamps already written this way stay where they
are; this release does not clean them up.
An enforced-write vault says why it refused a note. With
MARKDOWN_VAULT_MCP_OKF_WRITE on, the server stamps provenance into each
note it writes, so a note whose frontmatter block cannot be parsed has
nowhere to carry the stamp and the write is refused. That much was already
true; what reached the client was the raw complaint from the frontmatter
library, naming no note and reading like a server fault
(#1454). It
now reads as the refusal it is, naming the note, the reason, and the parse
error behind it, in the same form as every other rejected write. Nothing is
written either way. With the layer off, the write lands and the note simply
stays out of the index until its frontmatter parses.
A byte-order mark no longer hides a file's frontmatter. Every read of
vault markdown strips a leading UTF-8 BOM, a contract from
#673. Three
readers opened their own handle with plain utf-8 and sat outside it
(#1407). A
BOM in front of the opening --- then hid the block from the parser
underneath. The effects were spread out: a bundle whose root index.md
declared okf_version behind a BOM stayed inactive, so no OKF annotations,
no filters, and okf_validate reporting the vault as undeclared; a fully
conformant note audited as missing_type, understating the conformance
ratio; and a _conventions.md file handed the agent its raw YAML as though
it were convention prose. All three now read through the same helper as the
rest of the vault. A file without a BOM is unaffected, and a file that is
not UTF-8 at all is still skipped.
A link with any URI scheme is external. The external-link filter was an
allowlist of four prefixes, so a file:, ftp:, obsidian: or zotero:
destination was resolved against the source note's folder and reported as a
broken vault link, and a wikilink such as [[https://example.com]] was
stored with .md appended
(#1335).
The filter now recognises a scheme by its shape, at every extraction site.
A single letter before the colon is still a Windows drive, not a scheme.
Existing vaults rebuild their index once on first start after upgrade, so
the stale rows go away.
Percent-encoded link destinations resolve. CommonMark defines a
markdown destination as a URL, so [x](/probe/b%5B1%5D.md) names
probe/b[1].md, and percent-encoding is the canonical way to write a
destination containing [, ], spaces or parentheses. Nothing decoded
them: the encoded spelling resolved to nothing, produced no backlink, and
was skipped by a link-updating rename, which reported success while leaving
the link pointing at a path that no longer existed
(#1332).
Markdown and reference-link destinations are now decoded before they
resolve, a rename rewrites them in the spelling the author used, and
rename and move_folder share the fix. Three escapes are refused rather
than decoded, because decoding them would invent a target: an encoded /,
an encoded NUL, and a sequence that is not valid UTF-8; such a destination
stays exactly as written and is still reported broken. Wikilinks are not
decoded, since Obsidian writes them literally. Existing vaults rebuild once
on first start after upgrade.
An embedded image or PDF is no longer a broken link. A wikilink whose
target carried a configured attachment extension had .md appended, so
![[diagram.png]] was stored as a link to diagram.png.md, a file that
cannot exist; a markdown link to the same file was stored as a link to a
path the index never holds, since attachments are served from disk and not
indexed. Either way get_broken_links reported the reference and
stats.broken_link_count counted it while the attachment sat readable in
the vault, about 12 of one vault's 90 reported broken links in one case
(#1333).
The link graph is now notes-only: a reference whose target has an
allowlisted extension is not a link, in any of the three link spellings and
whether or not it is an embed, the way image links  always
were. Under the * wildcard a suffix counts as an extension only when it
holds a letter, so [[Python 3.12]] and [[Version 2.0 plan]] stay notes.
A note whose only links were embeds is now an orphan in
get_orphan_notes and stats.orphan_count, which is what it was to the
note graph all along. The attachment allowlist has become an input to the index, so
changing MARKDOWN_VAULT_MCP_ATTACHMENT_EXTENSIONS rebuilds it once on the
next start, and existing vaults rebuild once on first start after upgrade.
What this deliberately does not do is make attachments part of the graph:
get_backlinks on an image, exists on an embed, and a rename that
rewrites the embeds pointing at the renamed file are the v5 feature
#1359.
A stray [ no longer turns pages of prose into a link. The inline-link
pattern let link text run across paragraph boundaries, so an unmatched [
early in a note paired with a ]( thousands of characters later. In the
report, "one entry's destination contains literal newlines," a second
entry's link text spanned "[~2,000 further characters spanning many
paragraphs]," and "a single get_broken_links call on this vault returned
several multi-kilobyte blobs of document prose"
(#1334).
Links are now matched inside one paragraph at a time, with the boundaries
taken from the CommonMark spec row by row (a blank line in any line-ending
spelling, a bare >, a thematic break, an underline heading, a heading, a
line that opens or deepens a block quote, a list item), and a destination
never contains a line ending. A link written on a heading, quote or list
line still counts, as does a link whose text soft-breaks onto the next
line. Three limits were chosen rather than met. The scanner tracks no open
quotes, items or fences, so a nested item indented four or more spaces is
continuation text. Every numbered-list marker starts a new
paragraph, not only 1., because converted PDFs are numbered lists and a
stray [ in one item must not pair into the next. And a destination
wrapped onto its own line inside the parentheses is not accepted, because
that shape is what the converted-PDF defect produced. Existing vaults
rebuild once on first start after upgrade.
A link written in any CommonMark spelling names the same note. An
inline link's destination was read as raw text up to the first ), so
the spellings CommonMark defines beyond the literal one resolved to
nothing: [x](<my note.md>) was stored as a link to sub/<my note.md>,
[x](note.md "title") to note.md "title", [x](a(b).md) to a(b, and
\( or & stayed as typed
(#1353).
The pointy-bracket form is how CommonMark spells a destination with a
space in it, so a note titled with spaces could not be linked without
percent-encoding. The destination is now read by the spec's grammar (the
<…> form, escaped or balanced parentheses, a trailing title, backslash
escapes and entity references) and decoded before it is resolved, in
reference definitions as well as inline links; an attachment written in
any of these spellings is excluded from the link graph like the literal
one. A rename keeps the form the author used, so <my note.md> becomes
<new name.md>; only a new name containing #, < or > gains a
backslash escape, so it cannot be misread as a fragment. One new limit is
deliberate: parentheses balance to three levels, the depth the spec's own
examples reach. Existing vaults rebuild once on first start after upgrade.
A duplicate note name resolves the way Obsidian resolves it. When
several notes share a name, a bare [[Note]] picked the shortest path
everywhere, and the design doc claimed a different rule (fewest path
components); neither had a source, and only the design doc's was wrong
outright. The maintainer ran the discriminating fixture on Obsidian 1.13.7
(#1350):
an exact vault path wins, otherwise a note in the linking note's own
folder, otherwise the shortest path. The resolver now does the same, so a
[[Note]] written beside a same-named note points at that neighbour, as it
does in Obsidian, and a tie between equally short candidates now falls the
same way on a cold build and an incremental one. Existing vaults rebuild
once on first start after upgrade.
Renaming an attachment says when update_links does not apply. The
attachment branch of rename never read the flag, so a call that asked
for a rewrite answered updated_links: 0 and nothing else, and "the two
halves of one documented parameter behave differently with no signal to
the caller"
(#1338).
The count was honest; the silence was the bug. The result now carries a
hint when update_links=true is passed for an attachment: references to
attachments are not tracked as links, so nothing was rewritten and the
notes embedding the old path still name it. A note rename, or an attachment
rename without the flag, carries no hint key at all.
Configuration and its documentation¶
The OIDC pages stopped steering readers into the wrong mode. The 4.1
configuration reference documented only oidc-proxy and marked its client
credentials required, while the recommended remote mode was, in the
issue's words, "expressed only by absence"; nothing distinguished choosing
it from not finishing the configuration
(#1248).
And the wrong choice was costly: proxy mode propagates into the identity
provider's configuration before anyone notices. The reference now
documents both modes, names MARKDOWN_VAULT_MCP_AUTH_MODE, and marks the
Required column as the proxy-mode set; a new "Choosing an OIDC mode"
section states why remote is the better default for this server
(template#559,
#1260).
Nothing about runtime mode selection changed; an existing deployment is
unaffected.
The configuration reference is now generated. The whole env-var surface in the configuration reference is produced from the config model itself and guarded in CI, so the tables can no longer drift from the code. One correction rode along: the JWT signing key documentation had claimed the key was required on Linux/Docker because the default was ephemeral; in fact the default is derived from the client secret and survives restarts (#1247).
Attachment extensions now accept the spellings people write. An
operator who set MARKDOWN_VAULT_MCP_ATTACHMENT_EXTENSIONS=PDF or .pdf
got an allowlist that matched nothing, with every attachment refused by an
error message listing the extensions they had just configured
(#1239,
#1280). Case
and a leading dot are now ignored, so PDF, .pdf, and pdf name the
same type. This only widens what is accepted; no working configuration
changes meaning. Library callers passing attachment_extensions= get the
same normalization.
The git guide now says what the remote must permit. It documented which
credentials to configure but not what the token had to be allowed to do, so
two ordinary first-run conditions each cost a long detour before the real git
error was found: a protected default branch that refused the push, and a
clone with no usable token, whose fetches failed
(#1337). The
managed-git section now states both prerequisites (push access to the
tracked branch, and a branch protection that refuses regardless of the
token's scopes) and points at
git_sync(direction="push") as the call that returns the remote's own
refusal message, with a note on why git push --dry-run is not a substitute
for it.
For library consumers¶
Vault construction is now settings-first: a VaultSettings object
(importable from markdown_vault_mcp.vault alongside Vault) replaces
what had grown into "a flattened config bus" of 35 keyword parameters
(#1158,
#1224). Every
legacy keyword still works. Deprecation is docstring-only, with removal
scheduled for the next major, but mixing settings= with a non-default
legacy keyword raises ValueError naming each conflict: pick one mode per
construction. Collaborator arguments (embedding_provider, summarizer,
git_strategy, on_write, chunk_strategy) and source_dir stay as
keywords and are not deprecated. See the Vault API page.
Under the hood, the concrete classes now sit behind protocols:
git_strategy is typed against a VersionedStore protocol rather than the
concrete GitWriteStrategy, and markdown_vault_mcp.git and
markdown_vault_mcp.interfaces export the protocol names. A consumer who
passes a GitWriteStrategy needs to do nothing; the type only widened
(#1237). The
protocols are documented in the design notes rather than the API reference.
One inherited behavior note from
fastmcp-pvl-core 6:
make_server() now finalizes instructions synchronously and must not be
called from a running event loop.
Upgrading¶
No breaking changes. No env var, tool, or importable name changes
meaning; the operator surface only grew (two GitLab webhook variables,
INSTANCE_DESCRIPTION). A few things do change behavior without any action
on your part:
- The text index rebuilds once on first start. Seven changes in this
release alter how stored rows derive from a note's bytes: skip
tombstones
(#1227),
any-scheme external links
(#1335),
percent-decoded destinations
(#1332),
attachment references leaving the link graph
(#1333),
links bounded to one paragraph
(#1334),
destinations read in every CommonMark spelling
(#1353),
and the wikilink tie-break observed on Obsidian
(#1350).
INDEX_SEMANTICS_VERSIONmoved from 2 to 9 (the values between never shipped; a vault on 4.2.0-rc.0 holds 3 and rebuilds once more). One rebuild covers all seven; it is automatic, costs CPU and local IO proportional to vault size, and does not re-embed. Expect the link graph to change on it:get_broken_linksandstats.broken_link_countdrop (attachment embeds, schemed URLs and stray-bracket prose leave, encoded and CommonMark-spelled links now resolve),get_orphan_notesandstats.orphan_countcan gain notes whose only links were embeds, and in a vault with duplicate note names a bare[[Note]]can re-point to the note beside it, as it does in Obsidian. - A note becomes stale one day earlier, and an instant is honoured.
stale_afteris now the first stale day rather than the last current one (#1357), so a note dated today flips tostale: trueon upgrade; a note whosestale_afterwas a quoted instant ('2026-01-01T00:00:00Z'), which the server used to ignore, is now stale when that instant has passed (#1373). Triage listings built with{"stale": "true"}may gain entries, and may lose one for a note whosestale_aftercarried a time but no offset (previously read as its date, now ignored). No stored rows change, so no rebuild. generated.atmoves on every content write. It is now the UTC instant of the write rather than the date (#1372), so a rewrite of unchanged content, which used to leave the file byte-identical when the same actor wrote it on the same day, now changes the stamp and, on a git-backed vault, produces a commit. Stamps already on disk stay bare dates until the note is next written.- A vault whose root
index.mdstarts with a byte-order mark becomes an OKF bundle on upgrade (#1407). Its declaration was invisible before, so results gain OKF annotations, the OKF filters start matching, and reserved files and deprecated or stale notes take their ranking downweights. Becausetype,statusandstale_afterjoin the structured-filter set on an active bundle, that vault also rebuilds its index once, on the existingindexed_frontmatter_fieldsprovenance check rather than a semantics bump. Nothing on disk changes. okf_validatecan report index files it used to pass. A vault withMARKDOWN_VAULT_MCP_REQUIRED_FIELDSset sees eachindex.mdthe server wrote those fields into under the newindex_frontmatterfinding (#1396). Nothing is re-indexed or rewritten; do not remove the required fields, or the index drops out of search.- Two
fix(release)entries in the changelog (#1327, #1329) are repairs to the release pipeline itself, with no runtime effect. - A Voyage vault also re-embeds once, through a separate mechanism with a separate trigger: the provider's embedding behavior is part of the vector sidecar's identity, and asymmetric embedding changed it (#1270). This one costs Voyage API calls. Vaults on other providers load warm.
- A note that fails to parse stops serving its last-good content. It
drops from search and read until fixed, and is listed in
get_index_status.skipped_files. - Bulk calls produce one commit. Tooling that expected one commit per
file from
okf_convert_links,move_folder, or link-updatingrenamenow sees<tool>: N files. Single-file writes keep their old subjects. - On OIDC deployments with commit claims configured, commit authorship changes, from the static fallback identity to the authenticated user, which is what the configuration always promised.
- A stdio deployment with a webhook credential starts its file watcher where it previously ran nothing, and logs that the credential is inert on that transport.
- If your
INSTRUCTIONS_EXTRAdescribes what the vault contains, move that part toMARKDOWN_VAULT_MCP_INSTANCE_DESCRIPTION; keep rules of behavior where they are. Long instruction text now logs a budget warning at startup rather than being silently cut by the client. - Fresh package installs get a fully commented
env.example; every assignment now documents a default instead of setting one. A deployed/etc/markdown-vault-mcp/envis never overwritten.
This release is not the FastMCP 4 move: the template's v8 line and fastmcp-pvl-core 7 carry that migration, tracked for the next major as #1271.