Skip to content

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:

  1. Compose: set MARKDOWN_VAULT_MCP_SOURCE_DIR in .env to the vault's directory on the host. Move proxy labels and networks into an override. Without a proxy the server is reachable only on 127.0.0.1:8000; to serve other machines, configure authentication and publish from the override.
  2. Container port: if you set MARKDOWN_VAULT_MCP_PORT for the image, remove it and change the host side of the port mapping instead.
  3. Overwrites: decide whether writes may still replace existing files blindly. The default now refuses them; set MARKDOWN_VAULT_MCP_WRITE_PROTECT_EXISTING=false only where you need the old behaviour.
  4. Logging: replace FASTMCP_LOG_LEVEL with MARKDOWN_VAULT_MCP_LOG_LEVEL, remove FASTMCP_ENABLE_RICH_LOGGING, and set MARKDOWN_VAULT_MCP_LOG_FORMAT=rich wherever you want coloured output off a terminal. Update log queries and alerts: messages use the event_name key=value form, and tool refusals are INFO, not ERROR.
  5. Authentication: if MARKDOWN_VAULT_MCP_AUTH_MODE is set, make sure the variables its mode requires are set too.
  6. OKF review over bearer tokens: switch trust-auth to elicit with a client that shows the confirmation to a person.
  7. Python: move FastMCP 3 environments to FastMCP 4; move Vault keywords into VaultSettings and replace to_vault_kwargs; remove the two claim arguments from GitWriteStrategy; add set_commit_observer and start(on_tick) to any custom git_strategy; handle DocumentUnreadableError from read() and read_revision(); and handle the refusals from get_similar and list_tags where you relied on [].
  8. Vault directory: the server no longer refuses to start when its own MARKDOWN_VAULT_MCP_SOURCE_DIR is unset or missing (Compose still needs the .env value from step 1). Watch for vault_directory_missing in 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 matched tool_call_failed at 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 a setting= 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_similar and the similar:// resource refuse on a vault without embeddings, and list_tags and tags:// 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).
  • summarize reports notes it skipped in a new skipped list, each entry a path and a reason: not_found, invalid_path, over_read_limit or unreadable. notes_omitted now 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.md or log.md as if it were absent (#1627). On Python 3.14, where pathlib's existence checks swallow permission errors, the server now decides existence with stat(). 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_diff with an unknown commit is a refusal, not a git failure (#1632), and read with a bad revision is a refusal pointing at get_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).

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.

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=false skips the reindex that reconciles offline changes at startup. It defaults to true, the 4.2 behaviour. With it off, edits made while no server was running stay invisible until a reindex runs, through the reindex tool or markdown-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).

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() returns None only when no file exists. A note that exists but cannot be read raises DocumentUnreadableError, which is not a ValueError. read_revision() raises it too, for invalid UTF-8 and Git LFS pointers (#1627, #1635).
  • get_similar raises EmbeddingsNotConfiguredError without embeddings, and list_tags raises 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.