5.0¶
5.0 is a major release, and most deployments have something to change. The
server moves to FastMCP 4. Compose publishes on loopback without a proxy,
and logs default to JSON off a terminal. Writable servers refuse a blind
overwrite of an existing file unless the operator opts out. Python
consumers configure Vault through VaultSettings. Every tool call now
ends one of four ways: done, change the request, refresh and retry, or a
server fault. Refusals log at INFO with a next step; only real faults log
at ERROR with a traceback. Links the server writes work in Obsidian, the
link graph picks up link forms it used to miss, and large vaults no longer
pay a full-table scan per changed note. The index follows git HEAD, so
notes deleted by a pull leave the listings. OKF stops treating a bearer credential as
a person and keeps its generated index.md listings current. Read
Upgrading before you move.
This is the first stable release since 4.2. One outside report
shaped it: a 25,380-note vault whose reindex read over a terabyte from
disk. Two threads run through the rest. Breaking changes announced during
4.x now land: overwrite protection on by default, the
VaultSettings-only constructor, the claim arguments removed. And the
server now reports what happened truthfully to whoever asks, whether that
is a model calling a tool, an operator reading the log, or Python code
catching an exception.
Upgrading¶
Before moving a 4.2 deployment or integration to 5.0:
- Compose: set
MARKDOWN_VAULT_MCP_SOURCE_DIRin.envto the vault's directory on the host. Move proxy labels and networks into an override. Without a proxy the server is reachable only on127.0.0.1:8000; to serve other machines, configure authentication and publish from the override. - Container port: if you set
MARKDOWN_VAULT_MCP_PORTfor the image, remove it and change the host side of the port mapping instead. - Overwrites: decide whether writes may still replace existing files
blindly. The default now refuses them; set
MARKDOWN_VAULT_MCP_WRITE_PROTECT_EXISTING=falseonly where you need the old behaviour. - Logging: replace
FASTMCP_LOG_LEVELwithMARKDOWN_VAULT_MCP_LOG_LEVEL, removeFASTMCP_ENABLE_RICH_LOGGING, and setMARKDOWN_VAULT_MCP_LOG_FORMAT=richwherever you want coloured output off a terminal. Update log queries and alerts: messages use theevent_name key=valueform, and tool refusals are INFO, not ERROR. - Authentication: if
MARKDOWN_VAULT_MCP_AUTH_MODEis set, make sure the variables its mode requires are set too. - OKF review over bearer tokens: switch
trust-authtoelicitwith a client that shows the confirmation to a person. - Python: move FastMCP 3 environments to FastMCP 4; move
Vaultkeywords intoVaultSettingsand replaceto_vault_kwargs; remove the two claim arguments fromGitWriteStrategy; addset_commit_observerandstart(on_tick)to any customgit_strategy; handleDocumentUnreadableErrorfromread()andread_revision(); and handle the refusals fromget_similarandlist_tagswhere you relied on[]. - Vault directory: the server no longer refuses to start when its own
MARKDOWN_VAULT_MCP_SOURCE_DIRis unset or missing (Compose still needs the.envvalue from step 1). Watch forvault_directory_missingin the log if you relied on the refusal.
Clients: nothing to do beyond reconnecting. The tool set is unchanged, and a client picks up the new tool descriptions when it reconnects after the restart.
State: kept. Under Compose, the index, the embeddings and the OIDC state
stay on the state-data volume at /data/state, and no volume moves; the
new service-data volume stays empty. The first start rebuilds the text
index once, because 5.0 changes how its rows are derived from the notes.
The embeddings survive it: only a note whose sections came out different
from that rebuild is embedded again. On a large vault that restarts often,
consider MARKDOWN_VAULT_MCP_BOOT_REINDEX=false.
Security posture: narrower in three ways and wider in one. Compose
publishes on 127.0.0.1 only; a whole-file write or fetch onto an
existing file needs that file's etag; and a bearer credential no longer
counts as a person for OKF review. HTTP deployments gain the
unauthenticated /health and /health/ready routes, whose bodies
MARKDOWN_VAULT_MCP_HEALTH_DETAIL limits.
How a tool call ends¶
In 4.2, the server's own refusals reached FastMCP as unhandled
exceptions, so an ordinary refusal was logged at ERROR with a full
traceback. A write without if_match, an edit with a stale etag and a
get_outlinks on a missing path all looked like crashes in the log
(#1608).
An operator could not alert on ERROR without being paged for a model's
typo.
Every tool call now ends in one of four outcomes: the contract was met; the caller must change the request; the caller must refresh, then retry; or the server failed (#1615, #1641). What each party sees:
- In the log, a refusal is one line at INFO; a busy or still-building
index is one line at WARNING. A server fault is logged once, as
tool_failed, at ERROR with its traceback. Alerting on ERROR now means something broke. Log queries that matchedtool_call_failedat ERROR for refusals need updating. - The model gets a refusal message that names its next step: which
tool to call or what to pass instead
(#1643).
Refusals no longer name
MARKDOWN_VAULT_MCP_*settings the model cannot change; the setting goes to the log as asetting=field instead. For a fault, the model gets a fixed message saying "the request itself was fine; retry later, and tell the user if it keeps failing" (fastmcp-pvl-core#374). Git's stderr, which carries server paths, no longer reaches the model (#1632). - Python code gets a typed exception; see the library section.
The classification also corrected several outcomes that were misreported:
get_similarand thesimilar://resource refuse on a vault without embeddings, andlist_tagsandtags://refuse a field that is not indexed. In 4.2 all four returned an empty list, which a model could not tell apart from "no matches" (#1599, #1652).summarizereports notes it skipped in a newskippedlist, each entry a path and a reason:not_found,invalid_path,over_read_limitorunreadable.notes_omittednow counts only notes dropped by the note limit; 4.2 blamed unreadable notes on the limit too (#1637, #1644).- A note that exists but cannot be read is a fault, not a missing note.
Before this, OKF upkeep could overwrite an unreadable
index.mdorlog.mdas if it were absent (#1627). On Python 3.14, wherepathlib's existence checks swallow permission errors, the server now decides existence withstat(). An unreadable note is no longer purged from the index, and an unreadable embeddings store no longer triggers a cold rebuild over it (#1625, #1645, #1646). - A missing attachment is told apart from a refused read, and a transfer download answers 410 Gone only for a file that is truly gone (#1638).
get_diffwith an unknown commit is a refusal, not a git failure (#1632), andreadwith a badrevisionis a refusal pointing atget_history(#1635). A git vault with no commits yet returns an empty history.- A path holding a NUL byte is a bad request, not a server fault (#1636, #1642).
The tool, parameter and resource descriptions were rewritten to state each tool's contract and nothing else. Measured in UTF-16 units, tool descriptions went from 22,930 to 7,971 and input schemas from 28,128 to 21,778 (#1652). The tool set itself is unchanged. The tools reference and resources reference describe each refusal.
Overwrite protection is on by default¶
MARKDOWN_VAULT_MCP_WRITE_PROTECT_EXISTING now defaults to true. A
whole-file write or a fetch onto an existing path is refused unless it
carries if_match set to the target's current etag. edit, append,
delete and rename are unaffected. The failure being guarded is a model
calling write where it meant edit, and the issue that flipped the
default notes that "a default-off guard only protects operators who
already know the failure exists, which is the population least likely to
hit it"
(#1136,
#1477).
Deliberate replacement still works: read the note, then write with its
etag as if_match. An upload link has no if_match, so under protection
create_upload_link refuses an existing destination. To keep 4.2's blind
overwrites, set MARKDOWN_VAULT_MCP_WRITE_PROTECT_EXISTING=false. The
library default in VaultSettings stays False; the flip is in the
server configuration. See Configuration.
Two related tool fixes. The edit and rename descriptions now say when
to omit if_match: for several text-matching edits to one file at once,
and for renaming several linked notes together
(#1493,
#1572). A
line-range edit with an empty new_text deletes the lines; 4.2 left a
blank line behind
(#1607,
#1613).
Running the server¶
The server moves from FastMCP 3.4.7 to 4.0.10, through fastmcp-pvl-core
10 and the fastmcp-server-template 10.2 release
(#1271,
#1465,
#1550,
#1641,
#1649).
Most of what an operator notices comes through those upstreams.
Containers¶
compose.yml no longer assumes Traefik. It pulls the published image
instead of building, reads .env only if one exists, and publishes port
8000 on 127.0.0.1 because the quick start has no authentication. A
health check probes /health. The file also mounts a service-data volume
at /data/service, which this server doesn't use: its index, embeddings and
OIDC state stay on state-data at /data/state, as in 4.2. In .env,
MARKDOWN_VAULT_MCP_SOURCE_DIR names the vault's directory on the host,
which Compose bind-mounts at /data/vault, so it must be set; inside the
container the file sets the server's own value to /data/vault (#1465,
#1614). To
reach the server from other machines, configure authentication first, then
use a reverse proxy or publish on every interface from
compose.override.yml. The Docker guide has the
override, and the new security model page
explains what the loopback bind does and does not protect.
The image pins its listener to port 8000 and adds a HEALTHCHECK. In 4.2,
MARKDOWN_VAULT_MCP_PORT moved the container's listener; it no longer
does. To serve on another host port, change the host side of the port
mapping ("127.0.0.1:9000:8000")
(#1465).
Health and shutdown¶
HTTP deployments gain unauthenticated /health (liveness) and
/health/ready (503 when a readiness check fails) routes, placed beside
the MCP mount path. MARKDOWN_VAULT_MCP_HEALTH_DETAIL sets how much the
bodies reveal: status, standard (the default, which adds name,
version and per-check verdicts) or full (adds redacted reasons; for
trusted networks only)
(fastmcp-pvl-core#318,
#1465).
MARKDOWN_VAULT_MCP_SHUTDOWN_GRACE_S makes the SIGTERM drain window
configurable; its default of 3 seconds is the value 4.2 hardcoded
(fastmcp-pvl-core#331,
#1550).
Logs¶
Logs are structured as event_name key=value and have one format switch,
MARKDOWN_VAULT_MCP_LOG_FORMAT: rich for a terminal, json for a
collector. Unset, it picks Rich when stderr is a terminal and JSON
everywhere else, so a container or journald now receives JSON with no
configuration. FASTMCP_ENABLE_RICH_LOGGING is removed.
MARKDOWN_VAULT_MCP_LOG_LEVEL replaces FASTMCP_LOG_LEVEL; the old name
still works through 5.x and logs a deprecation warning
(#1548,
#1550). See
Configuration: logging.
Startup¶
A configuration mistake stops the server with one
ERROR: configuration error: ... line and exit code 1, including an
event-store error under --transport http
(#1551,
#1570). An
explicit MARKDOWN_VAULT_MCP_AUTH_MODE whose required settings are
missing now refuses to start. In 4.2, three such configurations "started a
server accepting unauthenticated connections": oidc-proxy without
client credentials, remote without BASE_URL, and a whitespace-only
bearer token
(fastmcp-pvl-core#338).
A vault directory that does not exist no longer crashes the server. It
starts, logs vault_directory_missing at ERROR naming the variable, and
every tool call fails until the directory is fixed; an unreadable
directory logs vault_directory_unreadable. For the server process, an
unset MARKDOWN_VAULT_MCP_SOURCE_DIR now means /data/vault rather than a
refusal to start. Managed git mode is exempt, since it clones into the
directory
(#1590,
#1591,
#1646).
Links¶
Links the server writes work in Obsidian¶
The OKF index generator, okf_convert_links and the link rewrite behind
rename and move_folder wrote destinations with literal spaces. The
indexer accepted them, but they are "invalid CommonMark links and fail in
Obsidian" (#1494,
#1514). All
three now write %20. They also escape the characters that would end or
re-point a link: #, an unbalanced parenthesis, and brackets in a title.
In 4.2, renaming a note to a name with an unbalanced ) rewrote its
links onto a shorter path, which pointed at a different note or at none
(#1516,
#1518).
Links already written with literal spaces are not repaired, and they are
still read and indexed.
The link graph holds more of what a reader follows¶
Backlinks, outlinks and broken-link reports come from the indexer's link extraction, which missed several valid CommonMark forms. It now reads:
- link text and reference labels containing an escaped
\](#1520, #1522); - links whose text contains an unmatched or nested
[, closing and opening at the nearest unmatched bracket (#1529, #1530); - shortcut references, a bare
[label]with a matching definition (#1531, #1533).
Across 97,655 bracket arrangements, disagreements with the markdown-it-py
reference parser went from 462 to 0 in the narrow set and from 5,574 to 0
in the wide one
(#1533). One
row goes away on purpose: ![alt][r] whose definition points at a note is
an image, and no longer counts as a link.
rename now rewrites the same links the index holds. In 4.2, a link like
[Bra\]cket](old.md) was left pointing at the old name while the rename
"still reports updated_links=1"
(#1521,
#1527). A
source the rewrite cannot change is no longer counted.
A file of unbalanced brackets could stall indexing: "A single such file
stalls indexing for the whole vault"
(#1343).
Wikilinks are now matched with a linear scan; 40,000 [ characters took
about 29 s and now take 40 ms
(#1524).
These changes alter what the index stores, so the first start after upgrading rebuilds the text index once, automatically.
Large vaults and git-backed vaults¶
Reindexing no longer scans the whole table per note¶
@humblesuperintelligence reported the cost on a vault of 25,380 notes: "every note that is added, modified, or reindexed pays one full-table scan", so a batch of changed notes costs time proportional to their number times the table size. A live incident "measured 1,526 GB read at up to 554 MB/s over 85 minutes" (#1535). Three changes address it:
- Removing a note's search rows goes by row ID instead of scanning the content table. Existing databases are migrated once, in SQL, on first open (#1539).
- A reindex pass checkpoints its progress every 30 seconds, so an interrupted pass resumes rather than starting over (#1540).
MARKDOWN_VAULT_MCP_BOOT_REINDEX=falseskips the reindex that reconciles offline changes at startup. It defaults totrue, the 4.2 behaviour. With it off, edits made while no server was running stay invisible until a reindex runs, through thereindextool ormarkdown-vault-mcp reindex(#1543).
Tuning SQLite's cache and memory map, also requested in #1535, was measured and not adopted; the maintainer found cache tuning "is counterproductive under exactly the memory pressure it was requested for" (#1535 closing comment).
The index follows git HEAD¶
In 4.2, a commit that deleted notes could reach the working tree through
a path that did not reindex. list_documents, list_folders and
get_broken_links then kept serving the deleted notes while read
reported them gone
(#1532).
The server now records the HEAD the index last reflected and reindexes
whenever the current HEAD differs, after every periodic pull, webhook
delivery and git_sync. Its own commits cost no extra scan
(#1560).
Link tools wait for recent writes¶
okf_convert_links could skip a wikilink whose target had been written
seconds earlier, because the index had not caught up
(#1464).
okf_convert_links, okf_generate_index, rename with update_links
and move_folder now wait up to 60 seconds for queued index updates
before touching files, and stop without changing anything if the refresh
fails (#1482).
See index freshness after writes.
OKF¶
A bearer credential is not a person¶
In 4.2, writes made with a static bearer token were stamped
by: human:bearer-anon in OKF provenance, while git attributed the same
writes to the configured agent identity
(#1463).
Static bearer, mapped bearer and client-ID-only callers are now service
principals: their writes stamp the server tool actor, and
MARKDOWN_VAULT_MCP_OKF_VERIFY=trust-auth refuses them for okf_verify.
Review clients on bearer authentication should use elicit, the default,
and show the confirmation to a person; an affirmative reply is recorded as
human:local. Existing stamps are not rewritten. OAuth tokens that carry
a sub claim keep their current attribution, pending
#1480
(#1481). The
OKF guide covers each
authentication case.
Generated listings stay current¶
With OKF_WRITE on, delete, rename and move_folder left a folder's
generated index.md listing notes that no longer existed, so
get_broken_links reported the server's own entries
(#1609).
They now refresh every listing they make stale. okf_generate_index also
wrote pointers to subfolder index.md files that did not exist
(#1647);
it now creates them, and reports them in a new created list
(#1648).
One visible effect: on an OKF_WRITE vault, the first refresh of an
indexed folder creates index.md in every folder beneath it that lacks one,
and on a git-backed vault those files land in commits.
The Python library¶
The public import surface did not change, but four construction and exception contracts did.
Vault takes VaultSettings. The 31 configuration keywords
deprecated in 4.2 are removed, along with both import paths for
to_vault_kwargs. Passing an old keyword raises TypeError
(#1225,
#1478):
from markdown_vault_mcp.vault import Vault, VaultSettings
vault = Vault(
source_dir=root,
settings=VaultSettings(read_only=False, index_path=index),
)
source_dir and the collaborators embedding_provider, summarizer,
git_strategy, on_write and chunk_strategy stay keywords. Code
building from ProjectConfig calls to_vault_instances(config), then
to_vault_settings(config, instances=instances), then Vault; see the
configuration migration and the
Vault migration.
Git strategies. GitWriteStrategy drops commit_name_claim and
commit_email_claim, and takes git_lfs and repo_path by keyword only;
operators keep the MARKDOWN_VAULT_MCP_GIT_COMMIT_*_CLAIM variables
(#1236,
#1479). The
startup warning about a missing git config user.email is gone with them.
A custom git_strategy must also provide set_commit_observer, and its
start() receives an on_tick callback, both part of the Syncer
protocol in markdown_vault_mcp.git.interfaces
(#1560).
See the Git API migration.
Exceptions. A new InvalidRequestError, a ValueError, marks every
refusal the caller caused; DocumentNotFoundError,
EmbeddingsNotConfiguredError and the new NoteTooLargeError derive from
it (#1628).
Code that caught ValueError keeps working, and an except ValueError
around a write now also catches a missing note. Two changes need code:
read()returnsNoneonly when no file exists. A note that exists but cannot be read raisesDocumentUnreadableError, which is not aValueError.read_revision()raises it too, for invalid UTF-8 and Git LFS pointers (#1627, #1635).get_similarraisesEmbeddingsNotConfiguredErrorwithout embeddings, andlist_tagsraises on a field that is not indexed, instead of returning[](#1652).
summarize's notes_omitted changes meaning as described
above. The
exceptions reference lists the classes.