v2.6.0 - Giving love to the MCP server
The MCP release. Agents get five new tools, a modern protocol revision (it's now fully stateless according to the latest MCP revision), and a get_tree that no longer hands over the entire Vault when you asked about one folder. Everyone else gets a Git schedule that actually fires, a read-only mount that actually mounts, and a write layer that stops refusing to rename a note because of the picture sitting next to it.
Important
Five things need action before you upgrade. Three status routes are gone, two PATCH /api/settings confirmations are refused, two MCP protocol revisions are no longer served, get_tree notes dropped a field, and the instance-wide HATCHDOOR_GIT_* variables stopped doing anything at runtime. See Breaking changes at the bottom.
New
MCP tools
The catalogue grew from 35 to 40, purely additively.
- Read and write a note's properties without touching its body.
get_frontmatterreturns just the tags, aliases and properties;update_frontmattermerges keys in and leaves your Markdown byte-for-byte identical. #175 get_frontmatterhands back the note'scontent_hashtoo. Every hash-protected write wants one, and the only way to get one was to pull the whole note body over the wire. A migration over hundreds of notes no longer reads every body to learn a 16-character string. #227get_attachmentpulls a file back out of a Vault, the mirror image of importing one. Default is a download URL; base64 inline is there for clients that can't fetch out of band. #176batchruns up to 50 reads or 20 writes in one call, with one commit for the lot. Hash chaining within the batch means an agent can create a note and edit it again three items later without a round trip to read it back. #177get_treetakesfolder,max_depthandinclude_notesnow. Asking for one folder used to cost you the entire Vault. The cheap orientation call (include_notes: false) measured 13x smaller than the full tree on a 530-note Vault, and 30x smaller atmax_depth: 1. #192, #211refresh_vaultasks one Vault for its next index turn. Collection reads have always said when they've fallen behind the Markdown, and an agent had no way to act on that.sync_vaultandretry_vaultboth resolve a Git poll interval first and refuse any Vault without a remote, so a plain local Vault had no MCP path to a rebuild at all. It answersqueuedon an idle Vault andcoalescedwhen a turn is already pending, so repeat requests can't pile turns onto one Vault. #228
MCP protocol
- The endpoint speaks protocol revision
2026-07-28: no handshake, stateless discovery, one opt-insubscriptions/listenstream instead of SSE subscribe/unsubscribe. Clients on2025-11-25carry on exactly as before. #169, #170 - Every tool now ships an
outputSchema, so a client can validate a response instead of guessing at its shape. #167 - MCP has a rate limit: 120 calls a minute per token, 8 concurrent calls, 2 concurrent searches. Over-limit requests get
429withRetry-Afterbefore dispatch. Turn the whole layer off withHATCHDOOR_MCP_RATE_LIMITS_ENABLED=false. #171 - The Vault asset route accepts an MCP bearer token, so an agent can fetch the download URL
get_attachmentgives it without also holding the web token. Same size ceiling, same quota, same concurrency budget as an MCP call. #174
Documentation
- The public documentation Vault gained Guides, Concepts and a full settings and environment variable reference, and the README now links into it rather than duplicating it.
- The public demo and the documentation site link to each other now. Each demo Vault points at the documentation page behind its layout, and the documentation's layout table links to the live Vault demonstrating each method.
Fixed
Indexing and freshness
- A Vault's read model could wedge permanently, and one new note was enough to do it. A slug comes from a filename alone, so two notes with the same stem in different folders compete for one, and the index build tried to give it to the arriving note while the departing one still held it. That failed the whole Index turn, and the next turn seeded from the same snapshot and failed at the same note, forever:
get_tree,get_graphandget_statsreportedpartialat a frozencollection_revisionwhile exact note reads stayed correct. The build now releases a moving slug before any note claims it, and a wedged cache recovers on its first turn, with nothing deleted and no cache wiped. #226 - An Index turn parked every write behind its embedding pass. It took the Vault's write lock and held it for the whole turn, so a write arriving during a multi-minute pass on a CPU-only host didn't slow down, it stopped. The caller's transport gave up on a write that then landed anyway, which is an at-least-once hazard the moment the caller retries. The lock now covers the turn's read phase only, and a Vault that took a write while it was released publishes itself stale rather than fresh, leaving the watcher's catch-up turn to sort it out. #223
- A sustained write burst deferred freshness for its whole duration. Every qualifying event restarted the watcher's 500 ms quiet timer with no upper bound, so a writer saving faster than that window armed no Index turn at all and the published snapshot couldn't advance until the writing stopped. The documented ceiling on that window is enforced now, and whichever of the two fires first wins. #229
- Every semantic search embedded your query twice, once to retrieve and again during diversity backfill. It embeds it once.
Vault writes
-
A note holding a real image, PDF or archive couldn't be moved, renamed, archived or deleted, and neither could the file itself. The check run after an asset move read the file as text, so any byte that wasn't valid UTF-8 rolled the whole write back with "refusing to move unsafe source". The suite stayed green on this because every attachment fixture wrote the ASCII string
pnginto a file named.png. The check now asserts an ordinary file and reads nothing. #220 -
A note referencing an asset outside its own folder couldn't be moved at all. The planner assumed every referenced asset was a sibling and built its destination by joining the reference onto the note's folder, so a link like
../Attachments/image.pngcarried its..all the way down to the move primitives, which refuse anything but plain names.An asset now travels with a note only when it already lives inside that note's own folder, or a subfolder of it. One kept elsewhere, such as the shared attachments folder of the usual Obsidian layout, stays exactly where it is: only the moving note's own link is repointed, and no other note is touched. A note in the Vault root has the whole Vault as its folder, so everything it references still travels when it moves. #225, #231
-
rename_noterefused any note that kept its picture beside it, and every note in the Vault root. A rename leaves the note in its folder, so an asset in that folder was already sitting at the destination computed for it, and the collision guard couldn't tell that apart from a genuine collision. A move to nowhere is now recognised as one: nothing moves, nothing is rewritten, andmoved_assetsreports0. A different file at an asset's destination still refuses, as before. #238 -
One rename could rewrite a Vault's prose out of the style it was written in. Every backlink to the moved note was retargeted to its full vault-relative path, so
[[Some Note]]came back as[[folder/subfolder/Some Note]]. The links still resolved, but the prose got noisier with every rename and nothing rewrote it back. A backlink now keeps the form it was authored in, with the full path as the fallback where the new title is one another note already carries. Aliases, anchors and fenced code are untouched. #235 -
A large note written through
update_noteorarchive_noteheld up other MCP traffic while it was written. Both tools now write off the request thread, the way the HTTP routes always have. Sharing one implementation also made three error payloads agree: a missing note names the Vault it looked in, an archive refused by exclusion patterns uses the HTTP wording, and a failed Vault scan reports the retryablevault_read_unavailableinstead of a generic internal error.
Git versioning
- If you redeployed more often than your Git poll interval, scheduled syncs never fired. Every process start re-armed the countdown from zero, so the only Git turns that ever ran were activations and manual syncs. The schedule was, in the strict sense, decorative. Each Vault now remembers when its last turn completed and resumes the countdown. #200
- Right after a restart, a Vault whose Git credentials had failed reported
pendingwith no error, potentially for a full day on the default interval. The last outcome is now republished at activation, error message and all. #200
Startup and hosting
- A Vault on a
:roDocker bind mount refused to come up at all, reporting the path as unavailable. Read-only mounts answer the write probe withEROFSrather than "permission denied", and only the latter was recognised. Such a Vault now activates read-only: browsable, indexable, writes refused. #178 - Setting
HOST=localhostfailed to parse and the server refused to start.localhostis now accepted as the IPv4 loopback, and the container health probe follows the listener's address family instead of guessing at it.
Web app
- After the backend restarted, the Vault list and its counts stopped updating until you reloaded the page. The client treated the new server's revision counter, which starts at 0 again, as stale. #194
- Opening a Vault's settings page during its own identity round trip could leave the page describing a Vault the Settings index disagreed with for the rest of the visit. #194
- Dragging the sidebar resizer moved the note path in the topbar and left the pane itself where it was. The layout grid redeclared
--sidebar-widthat its 280px default, and a local declaration beats the live value inherited from the shell.
Agents
- An agent that wrote a note was told Hatchdoor was still being set up, then handed model-setup tools that could not change anything. The routine reindex behind the write was sharing a status field with first-run model setup. #191
- Asking for a folder that no Vault has, across all Vaults, blamed one arbitrary Vault by id. The refusal now names none of them, because none of them is more at fault than the others. #211
Security
- Uploading or creating a file called
.hatchdoor-layerthrough the HTTP API silently reclassified an entire subtree as a demoted layer. The MCP tools had always refused this; HTTP now returns400 layer_marker_writetoo. Editing a marker you already have on disk is unaffected. - In demo mode, an attachment in an excluded or hidden layer was still served to anyone who knew its URL. Asset requests now go through the same browse surface as note reads.
- Bearer token comparison returned early on a length mismatch, leaking the configured token's length. Both sides are hashed to a fixed width before the constant-time compare.
Breaking changes, action required
1. Three instance-wide status routes are gone. GET /api/index-status, GET /api/git-status and GET /api/vault-status return 404. They each described a single Vault back when there was only one. The two Settings consoles they fed, Search index and Versioning, leave the Settings page with them; each Vault's own settings page keeps its condition, its errors and its Sync now, Try again and Rebuild search index buttons. #183
Action: point uptime checks at
/api/startup-status, which is unchanged, and anything asking about a specific Vault atGET /api/v1/vaults, which reports each Vault's condition, last error and indexing state.
2. PATCH /api/settings no longer accepts "confirm": ["git_init"] or ["git_downgrade"]. Those consequences belonged to the instance-wide versioning lifecycle that no current deployment reaches. reindex is unchanged and still confirmed.
Action: remove those two values from any API client, since an unknown consequence is refused as a validation error.
3. MCP revisions 2025-03-26 and 2025-06-18 are no longer served. The endpoint accepts exactly 2026-07-28 and 2025-11-25. A client pinned to a dropped revision is refused on the protocol-version header rather than silently downgraded.
Action: if an MCP client stops connecting after this upgrade, check which revision it pins. No configuration brings the old ones back.
4. Notes inside a get_tree result no longer carry vault_id. It was a third of the payload, and the tree already names its Vault once. Flat results that mix Vaults in one list, search_notes and recently_modified, keep theirs. #211
Action: read the Vault from the tree root.
5. The instance-wide HATCHDOOR_GIT_* and HATCHDOOR_EXCLUDE variables no longer do anything at runtime. They are read once when importing a pre-2.5.0 deployment, and validated at startup, and that is all. Saving one no longer reconfigures a Git repository as a side effect. HATCHDOOR_GIT_AUTHOR_NAME and HATCHDOOR_GIT_AUTHOR_EMAIL are unaffected and remain the fallback commit identity. #185
Action: set exclusion patterns and Git mode per Vault, in that Vault's own settings.
Under the hood
No behaviour change in any of these, but they are why the list above is as short as it is.
Eight refactors, expand if you care how the sausage is made
- The
/mcpprotocol boundary is thermcplibrary rather than a hand-written JSON-RPC layer. Tool names, arguments and response shapes are unchanged, as is the per-request security ordering. #168 - The legacy single-Vault indexing and Git lane is deleted. Hatchdoor was carrying a second, unreachable copy of both since v2.5.0. #185
- Every write, read and management operation now crosses one Vault-qualified core, with HTTP and MCP as thin adapters over it. That is why three MCP error payloads above could stop disagreeing with their HTTP twins. #184, #186, #187, #188
- Index turns and Git turns run behind a single Vault work executor. #197
- The frontend reads the Vault collection through one client instead of several. #198
- The eval harness is behind a non-default
evalcargo feature, cutting 17 crates from a default build. #195 - YAML parsing moved from the archived
serde_yamltoserde_yaml_ng. #196 - Unreachable search filters and the second retrieval path they gated are deleted. #210
Full changelog: v2.5.0...v2.6.0