edit_note
Edit notes in an Obsidian vault: replace content, append text, find-and-replace, or rewrite a section. Atomic writes and expected_hash guard against concurrent overwrites; dry_run previews changes.
Instructions
Edit an existing note in the Obsidian vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
See get_vault_guide for Obsidian syntax and any vault-specific conventions
(naming, folder placement, frontmatter, tags).
Four mutually exclusive modes (set at most one of append/find/section):
Full replace (default): provide only
content.contentbecomes the note's body; an existing valid line-1 YAML frontmatter block is preserved byte-identically ahead of it. Passreplace_frontmatter=Trueto overwrite the entire file, frontmatter included.Append:
append=True;contentis added at the end (preceded by a single newline).Find & replace:
find=<exact text>; replaced withcontent. Must match exactly once unlessreplace_all=True. This mode operates on the raw file, so it is the one mode that can edit frontmatter text in place.Section:
section=<heading>; replaces the whole body under the named ATX heading — see "Section mode: whatcontentreplaces" below. Use the path-style formParent/Childto disambiguate when the same heading appears more than once, or the#Nordinal form ("#7", 1-based document order) — the ordinal is the only form that can address duplicate headings sharing one parent, and it is the selector the outline of a truncatedread_noteadvertises. A bare "#N" always selects by position and is never shadowed by a heading whose text happens to be "#N"; reach such a heading by title with "Parent/#N". A selector resolves to the same section inread_noteas in this tool on any write this tool admits — that parity is about resolution, not about admission: see the two section-mode refusals below, where a section that reads fine is deliberately not writable. Setext (====/----) headings are not matched.
Frontmatter and the round trip. Read a note, edit the content field
of the response, pass it straight back to full replacement: the frontmatter
survives. No property of content's shape changes that — a body whose
first line is a thematic break ---, or which itself begins with a
complete mapping-shaped fenced block, is body. A note with no valid block
(no line-1 fence, or a malformed one) is replaced wholesale by default,
which is the repair path and needs no flag. To change the frontmatter
itself use set_frontmatter, or edit the raw block through find=; a
read_note response's frontmatter JSON view is a lossy convenience and
must never be written back.
The round-trip guarantee covers a complete, unwindowed whole-note read
only — read_note(path) with no section, offset=0 and truncated
false in the response. A truncated read must be paged to the end before it
is written back, or full replacement will replace the whole body with the
fragment.
Section mode: what content replaces.
In section mode
contentis the section's body: the text beginning on the line immediately after the matched heading line, running to the next heading of equal-or-shallower depth or to end of note. The heading line itself is never removed or rewritten.A section write replaces that body whole. Anything
contentdoes not resend is deleted — a blank line, a list, and a fenced code block sitting directly under the heading included. There is no third region between the heading line and the body that survives a write.So a blank line you want between the heading and its content belongs in
content(send"\ntext", not"text").read_note(path, section=...)is the matching read: its response carries the heading line in theheadingfield and the body in thecontentfield, and this tool takes exactly thatcontent. Pass the field through unchanged — there is nothing to split off and nothing to strip.Byte-identity holds for notes whose body newlines are LF. Every non-LF terminator inside the selected body (CRLF, or a lone CR) comes back as LF — the read path normalises and this tool writes raw bytes — whether the note uses one dialect throughout or mixes them. Terminators outside the selected body are untouched, so a round trip can leave a note with more mixed endings than it started with.
Section mode resolves headings over the frontmatter-stripped body, exactly
as read_note does, so #N ordinals agree between the two and a YAML #
comment inside the block is never selectable. A heading inside a fenced
code block is not a heading: fences count with up to three spaces of
indentation and a closer at least as long as the opener, and an unclosed
column-zero fence hides everything below it.
Two shapes refuse a section write outright, naming the problem and writing nothing:
a malformed frontmatter block (unclosed fence, YAML error, non-mapping) — the refusal names the defect and the
replace_frontmatter=Truerepair;a fence opener indented by one to three spaces that nothing below it closes — such an opener may sit inside a list item, whose code block ends where the item does, and this server does not parse container blocks, so it will not guess whether the text below is code or content. Close the fence or unindent it to column zero, then reissue.
Both refusals are asymmetric with reads on purpose: read_note(section=…)
and the truncation outline keep working on such notes, because a read
destroys nothing.
Flags:
operation="append": legacy alias forappend=True. This is accepted to prevent older clients from silently falling through to full replacement.operation="replace"explicitly selects full replacement.replace_all=True: withfind, replace every occurrence rather than failing on multiple matches. Ignored whenfindis unset.replace_frontmatter=True: full replacement overwrites the entire file including the frontmatter block. Combined with append/find/section it is an error and nothing is written.dry_run=True: compute the would-be result and return a unified diff without writing. Works for all four modes, and diffs the composed result.
Writes are atomic: the composed result is staged in the note's own directory,
flushed to disk, and published with a single same-directory rename, so a
crash mid-write cannot truncate the destination. The publish is guarded,
but only against a change landing inside this call: the file is read
here and re-compared immediately before the rename, so a writer racing this
tool's own read-modify-write fails with File changed while editing: <name> and nothing is written. That is not a guard on your read — it
is the second of the two windows below, and expected_hash is the first.
Structured frontmatter mutation is better done via set_frontmatter —
PyYAML serialization there discards YAML comments. A
path whose final component is a symlink is refused in every mode
(dry_run included), naming the link's target; symlinked folders inside
the vault work normally.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes, including today's silent overwrite of whatever
landed since your read_note. Pass the content_hash a read returned,
verbatim and canonical (sha256:<64 lowercase hex>), and the call is
refused with nothing written if the file changed in between. It is always
the whole file's hash, never a hash of the text you received. Two
windows, both live: expected_hash covers your read → this call's read,
the server's own pre-publication compare covers this call's read → its
publication, and a match on the first does not exempt the second. Every
refusal ends with one machine-readable MCP-REFUSAL {"code":…} line —
stale_precondition (with the file's current hash, ready to resend),
concurrent_write, no_incumbent, malformed_precondition,
precondition_unavailable, precondition_required — and each states what
resolves it.
It applies in all four modes, dry_run included, and is checked before
mode dispatch, the size cap, the diff and every no-op branch, so a stale
base never yields a diff or a "no changes" answer. A section= write binds
the whole file as well: #N ordinals are positional, so a body-only
digest could certify an unchanged body while an insertion above it changed
which section the selector names. That makes the narrowest mode the most
conflict-prone — an unrelated edit elsewhere refuses it — which is exactly
why the argument is optional: bind when you reasoned about what you read,
omit when you are appending to a log. Every mode that publishes reports the
content_hash of the bytes this call wrote (not of whatever is on disk
when you read the message), so an edit→edit chain needs no intervening
read; dry_run publishes nothing and reports none.
Args:
path: Vault-relative path to the note.
content: New body (full replace), replacement text, text to append, or
section body.
append: If True, append content to the end of the note.
operation: Legacy mode selector; accepts "append" or "replace".
find: Exact text to find and replace.
section: ATX heading text identifying the section whose body to replace.
Use Parent/Child to disambiguate repeated headings, or a "#N"
ordinal ("#7", 1-based document order) for duplicate siblings.
replace_all: With find, replace every match instead of requiring uniqueness.
dry_run: Return a unified diff and do not write.
replace_frontmatter: Full-replace only. If True, content replaces the
entire file including any frontmatter block. Default False
preserves an existing valid block.
expected_hash: The note's content_hash as you last read it. Refuses
the write, changing nothing, if the file has changed since.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| find | No | ||
| path | Yes | ||
| append | No | ||
| content | Yes | ||
| dry_run | No | ||
| section | No | ||
| operation | No | ||
| replace_all | No | ||
| expected_hash | No | ||
| replace_frontmatter | No |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| result | Yes |