Skip to content

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.md and b[1].md reported b1.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_diff spanning 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_history returned an empty list for a note the server could read (#1306, #1314).
  • On a SHA-256 repository, get_diff rejected the commit IDs get_history had 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 new MARKDOWN_VAULT_MCP_INSTANCE_DESCRIPTION variable 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 in MARKDOWN_VAULT_MCP_INSTRUCTIONS_EXTRA.
  • INSTRUCTIONS_EXTRA itself 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_INSTRUCTIONS full 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 ![alt](src) 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 &amp; 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_VERSION moved 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_links and stats.broken_link_count drop (attachment embeds, schemed URLs and stray-bracket prose leave, encoded and CommonMark-spelled links now resolve), get_orphan_notes and stats.orphan_count can 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_after is now the first stale day rather than the last current one (#1357), so a note dated today flips to stale: true on upgrade; a note whose stale_after was 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 whose stale_after carried a time but no offset (previously read as its date, now ignored). No stored rows change, so no rebuild.
  • generated.at moves 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.md starts 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. Because type, status and stale_after join the structured-filter set on an active bundle, that vault also rebuilds its index once, on the existing indexed_frontmatter_fields provenance check rather than a semantics bump. Nothing on disk changes.
  • okf_validate can report index files it used to pass. A vault with MARKDOWN_VAULT_MCP_REQUIRED_FIELDS set sees each index.md the server wrote those fields into under the new index_frontmatter finding (#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-updating rename now 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_EXTRA describes what the vault contains, move that part to MARKDOWN_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/env is 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.