Repository navigation
Releases
v0.11.0
A vault can now be served read-only: agents read and search it, nothing they call can change it, and the server keeps it current as a pull mirror (ADR-0030). Multi-vault serving stays one instance per vault — a read-only vault is a second instance with its own env file, hostname and connector name.
Added
MD_READ_ONLY=1(--read-only 1). The instance registers only the read and search tools, so every tool a client sees isreadOnlyHint: trueapart from an explicit pull. The serverinstructionssay the vault is read-only.- Pull-only
sync_vault. With git sync on, a read-only vault fetches and fast-forwards only — it never commits or pushes. It pulls once in the background at startup, everyMD_GIT_SYNC_INTERVAL_SECS, and whenever an agent callssync_vault. A branch that cannot fast-forward (diverged, or local edits in the way) fails the sync and changes nothing. READ_ONLYerror code. The core refuses every write on a read-only vault — a backstop the advertised surface never reaches.
Behaviour on a read-only instance
- The transfer API has no
PUT: it answers405 Method Not Allowed.provision_transferstill grants reads, and refuseswrite: true. - A pending transaction journal refuses startup; start the instance once read-write to recover it.
MD_GIT_AUTO_COMMITorMD_GIT_AUTO_PUSH_SECStogether withMD_READ_ONLYis a startup error.
Notes
- Serving one directory from both a read-write and a read-only instance is unsupported.
- No change for read-write instances.
v0.10.0
A note created as … I/O ….md landed as folder … I and note O ….md, and nothing could refuse it: with a string path, a/b.md and a title holding / are the same bytes. Every path the tools carry is now an array of segments (ADR-0029), so a separator inside a name is an error at the write that caused it, not a surprise at a later read — and the contract no longer states a separator character at all.
Breaking
- A path is
["dir", "note.md"], root to leaf;[]is the vault root. Requests, responses and error details alike, on every tool. A string path is refused at the schema. A segment that is empty,.,.., or holds/,\or NUL is rejected asSEGMENT, naming the segment; a title such asI/OneedsI∕O(U+2215) orIO. move_notesnames its target with exactly one ofdestorinto.destis the full destination path including the new basename;intois a directory to move into keeping the basename ([]for the root). The trailing-/convention that carried this distinction is gone; both or neither rejects the item asMISSING_CONTENT.list_notesitems carrykind(note|dir). Directory paths no longer end in/;kindis how a directory is told apart. A directory item'spathis exactly whatmove_notes.intotakes.- A transfer
PUTnever creates a directory. A URL cannot tell a separator from a/in a title, soPUT /api/notes/{path}under a missing parent answers409 Conflict(RFC 4918 §9.7) with the way out in the body: make the directory through the note tools (create_noteswith a path under it), then put again. The recipe says so on its PUT line.
Notes
- Markdown links inside note bodies and the transfer API's URL paths stay strings — each has a path syntax of its own. On disk, in the event journal and in git commit subjects nothing changed.
- The server
instructionsstate the array form up front, and name the intro note as the arrayread_notestakes. - A mistyped directory segment still creates that directory silently: this release catches separators, not typos. A
create_dirsguard stays a compatible follow-up if that failure shows up.
v0.9.0
A note too large to pull through model context, and a bulk job too repetitive for per-note tool calls, now have their own road: a byte-level HTTP transfer surface (ADR-0028). The MCP tools stay the normal way to work; this surface is the accelerator beside them.
Features
- New tool
provision_transfer. Returns a one-time ticket (300 s, single redeem) that trades for a scoped bearer token (1800 s, lapses instead of renewing — a lapsed token answers 401, like a wrong one), plus a ready-to-run curl recipe with every value already substituted. A grant can be read-only or read-write, and confined byprefixto one directory or to a single note; confinement is matched per path component, so a sibling likedir-evil/never leaks in. Transfer credentials are refused on the tool surface — they never widen back into full access. - The HTTP surface itself. An index (one JSON object per note:
path,etag,size— no content) that answersif-none-matchwith 304 for cheap polling; one-note GET/PUT where the ETag is blake3 over the exact bytes served, frontmatter and line endings included. Replacing an existing note requiresif-match(428 without it, 412 when stale,if-match: *to overwrite knowingly); creating takes no header. A subtree can be pulled in one round trip as tar, and a push can be dry-run checked before it is made. Every PUT rides the vault's per-write auto-commit, the same as a tool write. create_notesgainsbase. Pass the path of an existing note to copy it verbatim — frontmatter and body — instead of sendingcontent. Pure copy:baseexcludescontent/frontmatter(CONFLICT together, NOT_FOUND for a missing base), and edits belong to the other tools afterwards. Batch order is honored, so an item may copy a note an earlier item just created.- The vault owns its path rules. Notes are
.mdonly, names are stored in one Unicode spelling (NFC), and every way in — tools and transfer alike — obeys the same rules.
Breaking
- A note is UTF-8 text, enforced at the door. Bytes that are not valid UTF-8 are refused on write; a non-UTF-8 note synced in from outside reads as
ENCODING. - One note per transfer request. The bulk surface is the one-note primitives in a loop (the recipe ships the loop); the earlier aggregate endpoints are gone.
- A transfer token lapses instead of renewing. Renewal-by-presentation is withdrawn; provision a fresh ticket.
Fixes
- Transfer confinement hardening from adversarial review: prefix comparison composes a real path prefix, a note-confined grant no longer acts as a directory grant, protected directories are refused as prefixes, recipe URLs are percent-encoded (spaces,
#,&, CJK names), and the fetch loop is POSIX sh — no bashisms, NUL-delimited, parallel. - Recipe polish from field testing: a one-note grant drops the many-notes loop it cannot use; the PUT line warns that this surface carries frontmatter and body whole (putting back
read_notesoutput would erase the frontmatter it strips); TTL and the 401 answer of a lapsed token are stated where the token is collected. - OAuth token collection writes exactly the token — no trailing newline, so
$(cat token)is safe in a header. read_noteson a directory path reports the note as absent instead of erroring; the e2e gate expectation caught up.
Notes
- The deployed server announces its version in the MCP
instructions(md-mcp v0.9.0) after restart. - Deleting and moving are deliberately not part of the transfer surface:
delete_notesandmove_notesremain the only doors.
v0.8.2
A pause in the conversation no longer breaks the next tool call.
Fixes
- An idle HTTP session survives an hour instead of five minutes. rmcp reaps a session after
SessionConfig::keep_aliveof inactivity, and its default is 300 seconds — a threshold a conversational client crosses constantly. The session worker quit onIdleTimeout, the manager dropped the entry, and the client's next tool call, still carrying the session ID it had been issued, got the spec-mandated404 Not Found. That 404 is a transport-layer response with no JSON-RPC envelope, so the caller could not attribute it to an expired session: it surfaced as a failed tool and read as a bug in whichever tool happened to be called first after the gap.
Notes
- The timeout remains as the safety net it is meant to be — a session whose HTTP connection dropped silently is still collected, just an hour later.
- Sessions carry no vault state of their own (each is a cheap clone over the shared vault and the one commit lock), so a longer-lived session costs only its worker task.
v0.8.1
Editing a section no longer restyles the note around it.
Fixes
edit_sectionsreplacekeeps the blank lines that framed the replaced span. A section's content span begins right after the heading line and runs to the next heading, so the blank line under the heading and the separator ahead of the following heading were inside whatreplaceoverwrote. Content that did not carry its own leading newline silently produced## Aimmediately followed by body text and body text immediately followed by the next heading — spacing every Markdown formatter, and markdownlint MD022, expects a note to keep. The replacement now restores the span's own leading and trailing blank-line runs.
Notes
- Only ever restores what was there: a document written without blank lines around its headings stays that way, and nothing is added at a seam the note did not have.
- Idempotent for round-trips. Content echoed back from
read_sectionsalready begins and ends with those separators, so they are not applied twice — a note does not grow a blank line per edit. - A span that is nothing but separator (a heading with an empty body) contributes one blank line to each side rather than duplicating the whole run, so filling in an empty section lands correctly spaced.
append,insert_before/insert_after, andmoveare unchanged — their placement and seam rules were already settled in ADR-0025.replacenow follows the same principle against an existing span.- Spans,
content_hash, overlap footprints, and the wire schema are untouched; only the bytesreplacewrites change.
v0.8.0
A one-character typo fix no longer costs a full read-and-rewrite of the surrounding section (ADR-0027).
Features
- New tool
replace_text. One item = one (note,find) literal substitution inside a note body; frontmatter is never searched (that staysedit_properties' domain). Matching is byte-exact — no regex, no case folding, no CJK spacing/markup folding — so the replaced span is never in question. By defaultfindmust occur exactly once: 0 matches reject withNOT_FOUND, 2+ withAMBIGUOUS;replace_all: trueorexpected_count: <n>takes every match. Optionalheading_path(plusscopeandexpected_hash) narrows the search to one section, anddry_run: truereports the matches without writing. - Batches stay order-independent. Every item resolves its match spans against the unedited note and intersecting spans reject the batch (
OVERLAP), so no item ever sees another item's output. All-or-nothing, like the other destructive tools. - Responses report positions, not prose. Per item: the replacement count plus the first few changed lines, numbered in the resulting note — never the note body, which is precisely the payload the tool exists to avoid.
- New error code
COUNT_MISMATCHfor a violatedexpected_count.
Fixes
make checkis green again. The stdio end-to-end suite still readread_outlinesas a heading array, which v0.7.0 replaced with a text block, and four files no longer matched the rustfmt of the pinned toolchain — both gate stages had been failing.- The tool-annotation test now asserts that every registered tool is classified, which its comment claimed but the check did not do.
Notes
- Measured on a real vault (259 notes): median note 12.5 KB, median leaf section 425 B (p90 1.4 KB). The old typo path —
read_outlinesfor the address,read_sectionsfor the bytes,edit_sectionsto send them back — moved roughly a kilobyte in each direction for a few bytes of actual change. - Structural change (adding, deleting, moving, renaming sections) stays with
edit_sections:replace_textedits body text and does not validate heading levels. search_notesstill NFC-normalizes and folds CJK gaps, so a hit it finds may not matchfindbyte-for-byte (전역지침finds전역 지침; replacing it does not). This asymmetry is deliberate — search may guess, a destructive edit may not — and the tool description says so.
v0.7.0
Outlines are re-encoded as text rows addressed by minimal unique suffixes (ADR-0026), cutting the vault-wide outline payload to ~22% of the previous encoding.
Breaking changes
read_outlinesreturns a text block, not a heading array.NoteOutline.headingsis replaced byNoteOutline.outline: one<line> <#×level> <title>row per heading in document order, following theread_notescontent-string envelope pattern. The grammar is unambiguous by construction — titles cannot contain newlines, heading rows start with a digit, marker rows with↳. The response budget now measures rendered rows.- Heading addresses are minimal unique suffixes. Each heading carries the shortest ancestor-chain suffix that
resolve_headingmaps back to it, emitted as a↳ heading_path: [...]row only under headings whose bare title does not resolve.occurrencebecomes optional and is set only when even the full chain is duplicated; theambiguousfield is dropped — a multi-element address is the signal, and it answers the question agents actually have: does the bare title suffice?
Fixes
search_notesmatches CJK terms across spacing and inline style. Korean spaces words optionally, so전역지침and전역 지침are one term, but the query was split on whitespace and matched as literal substrings — the two directions disagreed, and**전역** 지침never matched전역지침. A second scan now runs over a shadow copy with CJK-adjacent gaps folded, unioned with the raw scan; snippets andmatch_countstill report original bytes.- The Windows build compiles again. The OAuth token store reached for unix-only file-permission APIs, so
x86_64-pc-windows-msvcfailed to compile and no Windows binary was attached; closes #10. - Ambiguity errors point at the outline's
↳address rows instead of a heading flag the outline no longer emits.
Notes
- Measured on a real vault (156 notes / 3107 headings): 99.2% of headings resolve by bare title, and every emitted address round-trips through
resolve_heading. - The
read_outlinesdescription now names the ~10 KB threshold (seesize_bytesinlist_notes) above which outline-first reads pay off, and states the small-note fast path explicitly. - The OAuth store stays owner-only (
0600) on unix; Windows has no mode bits, so it inherits the state directory's ACL — keepMD_STATE_DIRunder a per-user location. - The Windows release asset remains best-effort and does not block the Linux binary.
v0.6.2
Patch release: insertion placement now follows authoring intent (ADR-0025).
Fixes
appendcontinues the section's text. It used to insert at the raw span end — after the blank lines separating the section from the next heading, gluing the new text to that heading (lead\n\nmore\n## B). It now backs up over the trailing whitespace-only lines to sit directly after the last non-blank line, leaving the separator in place (lead\nmore\n\n## B). A span ending at EOF keeps the literal end (matchingappend_notes); no separator is ever added or removed.insert_before/insert_afterkeep blank-line seams. Inserted sibling blocks were glued to both neighbours; they now get the same both-seams blank-line rulemovealready applied (only ever adds, never removes), via one shared implementation.
Notes
- Spans,
content_hash, overlap footprints, and the wire schema are unchanged — only insertion offsets and seam padding moved. Byte-level output changes for callers that pinned the old placement. - The
edit_sectionsdescription now spells out placement, including thescope:"section"trap: plain text appended at a subtree's end structurally joins the last subsection — usescope:"body"for the section's own prose.
v0.6.1
Patch release: MCP structured-output schema conformance and gitfile-aware exclude hardening.
Fixes
- Output schemas no longer require condensed-away fields. Fields dropped from the wire when empty/false (
omitted,errors,conflicts,pruned,dry_run, …) were still listed asrequiredin every tool'soutputSchema, so clients that validatestructuredContentagainst it (e.g. recent Claude Code) rejected virtually every successful call. All 17 such fields across 7 response types are now marked optional; per-module regression tests pin the invariant.
Features
.gitgitfile support for exclude hardening. A vault whose.gitis a file (git worktree, submodule,--separate-git-dir) now gets.md-mcp/and.*/written into the real repository'sinfo/exclude— followingcommondirfor linked worktrees — instead of being skipped, so the auto-commit sweep no longer pushes server state or hidden dot-dirs from such vaults. Git sync itself already worked in these layouts.
v0.6.0
Unified move primitive: move_notes replaces rename_notes and relocate_notes (ADR-0024).
Breaking changes
rename_notesandrelocate_notesare removed. Migrate tomove_notes; each item is{ source, dest }:destending in/— move into that directory, keeping the source basename (/alone is the vault root). Covers the oldrelocate_notes.destwithout a trailing/— the full destination path including the new basename (a note keeps.md). Covers the oldrename_notes, and move + rename in one atomic step (previously two calls).
- The tool surface drops from 12 to 11 (plus opt-in
sync_vault).
Features
- Nested destinations (final-tree semantics) — an item may land inside another item's moved directory in the same batch (e.g. move a directory and file more notes under its new location, atomically). Ancestor destinations apply first; closes #7.
- Move + rename in one call — the core restructure action is now one atomic, all-or-nothing batch item; closes #1.
Notes
- All existing validation carries forward: vault jail, collision/overlap/swap rejection, all-or-nothing batches,
dry_run,overwrite,prune_empty,update_links(vault-wide standard-Markdown link rewriting), and the Unicode same-file respelling exception. - A destination inside a subtree the batch vacates is still rejected (
BATCH_COLLISION); cyclic swaps remain rejected. - The README is rewritten for end users: tool overview, safety properties, and MCP client quick start.