Repository navigation
[Bug] patch_vault_file replace corrupts sections containing markdown tables with inline code-span heading refs #83
Description
Activity
Hi @folotp — thank you for the repro, this is a clean isolation of a distinct bug. Confirmed it's not covered by anything in the community fork today (
istefox/obsidian-mcp-connectorat v0.3.5) and it's logically orthogonal to #71 and #78.Your root-cause hypothesis matches mine: the section-boundary scan in
markdown-patchis regex-based on^#{1,6}\swithout maintaining fenced-code / code-span / table-cell tokenizer context. Wrapping the scanner in a proper markdown tokenizer (remark / marked / markdown-it with the table and gfm extensions) would solve it end-to-end.Where the fix needs to live
- Primary: upstream in
coddingtonbear/markdown-patch. That's where the boundary detection logic actually runs (viaobsidian-local-rest-api'sPATCH /vault/{filename}handler). A wrapper-side fix would be duplicating part of the parser. Worth opening an issue onmarkdown-patchwith your fixture — if you haven't already, I'm happy to do it and cite you. - Wrapper-side mitigation (fallback if upstream is slow): add a defensive pre-check in the fork that tokenizes the target file before issuing
replace, verifies the resolved section boundary is not inside a code span / fenced block / table row, and fails loud (or falls back to overwrite semantics) if it detects ambiguity. Bigger lift — probably only worth it ifmarkdown-patchstays unresponsive.
Interim guidance for users reading this
Your workaround section is accurate. I'd add one concrete alternative that works well for small-to-medium notes: fetch the current body with
get_vault_file, apply the mutation in the MCP client's own context, and write it back withcreate_vault_file(overwrite). This sidestepsmarkdown-patchentirely at the cost of a round trip.I'll open the upstream issue on
markdown-patchin the next day or two with your fixture unless you prefer to file it yourself — just say the word. Either way, I'll link the upstream issue back here once it's open so this thread has the forward pointer.Cross-refs: #71 (block-in-table gap, separate follow-up), #78 (HTTP header UTF-8, already fixed in fork 0.3.0), #81 (frontmatter array, PR inbound).
- Primary: upstream in
Upstream filed: coddingtonbear/markdown-patch#10, with your fixture and root-cause hypothesis credited. Leaving this thread open for any fork-side mitigation we decide to add in the meantime.
Reacted by Pierre-André FolotCircling back since #79 means this thread is unmaintained on upstream. The community fork
istefox/obsidian-mcp-connector0.4.0(released 2026-05-04) doesn't go throughmarkdown-patch: the in-process plugin has its ownapplyPatchinpatchHelpers.ts:442–448that uses a line-start regex^(#{1,6})\sfor section-boundary detection.Hand-trace of your fixture (
fixture-table-codespan.md) against that regex: the table cell line containing`## Links`starts with|, not#—^#{1,6}\sdoesn't match it. The scan correctly identifies the real## Linksheading as the section boundary. Replace then slices whole lines between## Journaland## Links, splicing in the new content. The cell content with the code span is part of the removed old body, not the boundary-scan input.So structurally the fork sidesteps this specific bug — not because it's parser-aware, but because its naive line-start regex happens to match only real-heading lines (table cell text, code-fenced block content, indented
#-as-character lines all fail the^anchor). 31 tests on the patch helpers but none on this exact scenario, so this is a code-trace claim, not a soak-verified one.If you have time to retest your fixture against
0.4.0, that'd close the verification loop. Otherwise filing oncoddingtonbear/markdown-patch(your suggestion in the issue body) is still the right move for the upstream library — the fork dodges the issue by not using the library, but anyone else on the 0.3.x binary line or other downstream consumers still hits it.— Stefano
- added a commit that references this issue
on May 4, 2026 @istefox Tested against 0.4.1 (stable, HTTP-embedded transport) on 2026-05-04. The bug reproduces — the
^anchor claim in the code trace doesn't hold in practice.Environment: istefox/obsidian-mcp-connector 0.4.1 (HTTP-embedded transport,
127.0.0.1:27200/mcp), Obsidian 1.12.7, macOS 26.x.Fixture (equivalent to
fixture-table-codespan.mdfrom the issue body):# Root ## Journal Original content. | Column A | Description | | --- | --- | | item 1 | ordinary item | | ref | `## Links` | ## Links Original links content.
Call:
patch_vault_file( filename: <fixture>, target: "Root::Journal", targetType: "heading", operation: "replace", content: [replacement — includes the same table with `## Links` in last row], createTargetIfMissing: false )Observed result (file content after patch):
# Root ## Journal [replacement content — table intact including `## Links` row] ## Links` | ## Links Original links content.The full replacement content is inserted correctly, including the table row
| ref | `## Links` |. But a spurious## Links|line appears after it — the tail of the original file's table row, split at the`preceding## Links.What's happening: the section-boundary scanner runs over the original file to locate where
## Journalends. It encounters| ref | `## Links` |and matches## Linksinside the line — not at the start. It splits there: everything before## Links(i.e.| ref | `) is included in the section content to replace; the tail## Links|is preserved as the boundary marker. The replacement content is inserted before this false boundary, and the real## Linksheading follows after it.The
^anchor in^(#{1,6})\s(patchHelpers.ts:442–448) should prevent matching mid-line, but it clearly isn't. The regex is either applied without the^, or in a single-line/non-multiline mode where^doesn't anchor to individual line starts. Worth checking whether the heading scan in that range splits on newlines before applying the regex, or runs it against the full section string with agflag.@folotp — my repro on the same fixture shape produces a clean output. Either we're testing different versions or the actual replacement content differs from what's described in the reply. Let me lay out what I tested so we can pinpoint the disconnect.
Test on
feat/http-embeddedHEAD2387e0e(post-0.4.1):I copied your fixture verbatim into a unit test calling
patchVaultFileHandlerwithoperation: "replace",target: "Root::Journal",content: "[REPLACEMENT BODY]". Resulting file content:# Root ## Journal [REPLACEMENT BODY] ## Links Original links content.No spurious
## Links` |line. Test passes 1/1.The boundary scan at
patchHelpers.ts:442-448runslines[i].match(/^(#{1,6})\s/)per individual line;lines[i]is one element fromrawContent.split("\n"), so^anchors to position 0 of that single-line string. The table row| ref | `## Links` |starts with|, the regex returnsnull, and the scan correctly identifies the real## Linksheading at line index 11 as the boundary.Possible explanations for the disconnect:
-
BRAT cached an older version. Could you call
get_server_infoand post theapiExtensions[0].versionfield you actually ran against? On0.4.1it should reportapiExtensions[0].version = "0.4.1". If it shows0.4.0or earlier, BRAT didn't pick up the update on this round (the heading-blank fix in0.4.1is unrelated to this bug, but the version pin matters for the trace below). -
Replacement
contentshape differs from the paraphrase. Your reply describescontentas[replacement — includes the same table with \## Links` in last row]— that's a paraphrase, not the literal string. If the actual replacement string contains a literal## Links\n*outside* a code-span, the output would have a## Linksline mid-body — but that's the replacement value, not a section-boundary bug. The boundary scan only runs over the *original* file's lines, never over the new content. Could you paste the exactcontent` string you sent? -
A different code path I'm not seeing. If neither (1) nor (2) explains it, I'd like the exact tool-call payload you sent (full
content, exacttarget) so I can run it locally byte-for-byte. If your local install still produces the spurious line on the same payload my test handles cleanly, we have a real bug and I cut0.4.2immediately.
Section-boundary corruption is data-loss-class, so I take the report seriously. But the test suite, the static code-trace, and the live unit-test repro all agree the current code works correctly on the fixture as written. Before patching, I want to pin down where exactly the live observation diverges from the unit-test observation.
— Stefano
-
- added a commit that references this issue
on May 4, 2026 @istefox — answering your three checkpoints in order, then a variant matrix that I think reframes the bug. Bug reproduces on
0.4.1, and the trigger is broader than the original report suggested.1. Version pin
get_server_inforeturns:"apiExtensions": [ { "id": "mcp-tools-istefox", "version": "0.4.1", ... } ]
Plugin runtime is 0.4.1. BRAT did pick up the update.
2. Exact tool-call payload (canonical repro)
Replacement
content(literal string):Replacement journal content. | Column A | Description | | --- | --- | | new item | replacement | | new ref | `## Links` |Tool call:
filename:fixture-table-codespan-retest.mdtarget:"Root::Journal"targetType:"heading"operation:"replace"createTargetIfMissing:false
The`## Links`token in the replacement is inside backticks and inside a table cell — same shape as the original. No bare## Links\nheading incontent.
3. Fixture (verified by read-back before patching)
# Root ## Journal Original content. | Column A | Description | | --- | --- | | item 1 | ordinary item | | ref | `## Links` | ## Links Original links content.Result (byte-exact file content after patch, re-read from disk)
# Root ## Journal Replacement journal content. | Column A | Description | | --- | --- | | new item | replacement | | new ref | `## Links` | ## Links` | ## Links Original links content.The orphan
## Links` |is the tail of the original cell| ref | `## Links` |, split immediately before## Links— a mid-line split.Variant matrix — the bug is broader than tables-with-code-spans
I ran four variants pre-emptively to narrow the trigger surface. All use replacement
content: "REPLACEMENT.\n"(constant) andtargetType: "heading",operation: "replace",createTargetIfMissing: false. Each fixture was created, patched, read back from disk, deleted.# Variant Original-section-body shape Target Reproduces? Orphan after REPLACEMENT.A Leaf-only target code-span in table cell (canonical) Journalyes ## Links` |B Single-row table code-span in table cell, 1 data row Root::Journalyes ## Links` |C Inline plain prose, no table, no code-span line: Original content. This refers to ## Links below.Root::Journalyes ## Links below.D Code-span in paragraph, no table line: Original content. See the `## Links` section below.Root::Journalyes ## Links` section below.The decisive variant is C. The original section body has no table, no code span, no fence — just a plain prose line
Original content. This refers to ## Links below.. The orphan after replacement is## Links below.— i.e. the substring of that prose line from the first##onward, treated as a section boundary.This rules out table-cell context, code-span context, and (via A) nested-heading resolution as the trigger. It also makes the original issue title slightly misleading — the bug is not specific to "tables with inline code-span heading refs"; it fires on any
##substring anywhere in the section body that is not at line start.What this rules in
The result is consistent with the boundary regex being applied without an effective line-start anchor. Concretely, one of:
- the regex is
/(#{1,6})\s/(no^) on the live path, even if^is present in the unit-test path; - the regex is
/^(#{1,6})\s/gapplied to the section body as a single string with nomflag —^only anchors to position 0 of the whole string, andglets it walk forward, matching##anywhere mid-string; - the input to the regex is not pre-split on
\non the live path (whatever code doesrawContent.split("\n")in your unit test isn't on the path the HTTP-embedded route handler takes).
The first match in each variant is the first occurrence of##in the section body — the regex isn't even reaching the real## Linksheading. The real heading still appears in output because it's downstream of where the boundary marker landed: the scan stopped early, so the section content was truncated at the false boundary, but everything from the false boundary onward (including the real## Linksheading and its body) was preserved.
Where the unit-test trace and the live trace diverge
A per-line scan with
^would not match any of variants A–D's offending lines:- A, B, canonical: line starts with
|. - C: line starts with
O. - D: line starts with
O.
Yet all four reproduce. The unit test's per-line behavior cannot be what's running on the HTTP-embedded path, even though it's nominally calling the samepatchVaultFileHandler. Worth checking whether there's a wrapping layer between the MCP HTTP route andpatchHelpers.ts:442–448that hands the section body off as a single concatenated string, or applies a different scanner entirely (perhaps an inherited 0.3.x helper).
Offer
Happy to act as a remote test bench:
- if you push a build with stack traces / debug logs around the boundary scan, I'll rerun any of A–D and post the regex input string + match index;
- if you push a candidate fix, I'll verify all four variants on the live path before you cut
0.4.2; - additional variants on request —
##at column 1 of a fenced code block,##inside<!-- HTML comment -->, multi-byte chars before the##, etc.
— PA
@folotp — variant matrix is a sharp piece of work, especially the C/D pair that rules out the table-cell and code-span context. That diagnostic narrowing is what makes this thread useful.
I went through the source-and-bundle branch of the diagnosis, and hit a wall there. Sharing the path so you can sanity-check the reasoning and we can pinpoint where our two views diverge.
Source state on
feat/http-embeddedHEAD (=0.4.1source)git diff 30ef3c9..HEADoverpackages/obsidian-plugin/srcis empty — the0.4.1tag and the current HEAD are bit-identical on every patch-related file. Doc-only commits since the tag.There are exactly three
#{1,6}regex patterns in the plugin source, all inpatchHelpers.ts, all with explicit^anchor applied to per-line elements afterrawContent.split("\n"):patchHelpers.ts:40—resolveHeadingPathleaf scanpatchHelpers.ts:417— leaf-name match in the heading branch ofapplyPatchpatchHelpers.ts:443— sibling-or-higher boundary scan inapplyPatch
Grep over
packages/obsidian-plugin/srcfor any other heading scanner returns nothing. There is no compat shim forPATCH /vault/, noapiExtensionlayer between the MCP HTTP route andpatchVaultFileHandler, andpatchVaultFileHandlerdelegates straight toapplyPatch. The MCP HTTP path is the unit-test path.Bundle integrity check (your "different scanner" hypothesis)
I rebuilt
main.jsfrom HEAD source and diffed against the shipped0.4.1artifact:- size delta: 6 bytes
- sha256: differ
cmp -lshows divergence starting at offset 1633466- the divergent bytes are
__dirname/__filenamestrings insideonnxruntime-web(/Users/stefanoferri/...locally vs/home/runner/work/...on CI) - the regex pattern is bit-identical in both bundles:
let C=$[E].match(/^(#{1,6})\s/);if(C&&C[1].length<=D){…
No scanner divergence in the deployed code.
Unit-level repro of your variants
I wrote a fresh unit test that mirrors your fixture C and your fixture A canonical byte-exact, with the exact tool-call arguments you reported:
Variant C (plain prose, no table, no code-span;
target: "Root::Journal",content: "REPLACEMENT.\n")- input →
# Root\n\n## Journal\n\nOriginal content. This refers to ## Links below.\n\n## Links\n\nOriginal links content. - output →
# Root\n\n## Journal\n\nREPLACEMENT.\n\n## Links\n\nOriginal links content.
Variant A canonical (table with code-span;
target: "Root::Journal", your full multi-line replacement string)- input → as in your fixture
- output →
# Root\n\n## Journal\n\nReplacement journal content.\n\n| Column A | Description |\n| --- | --- |\n| new item | replacement |\n| new ref | \## Links` |\n\n## Links\n\nOriginal links content.`
Both clean. No mid-line split. No orphan
## Links below., no orphan## Links` |. Trace for C:lines[i].match(/^(#{1,6})\s/)walksi=3..6; ati=4the line isOriginal content. This refers to ## Links below.and the^anchors to position 0 (O), so the regex returns null;i=6matches at## Links,sectionEnd = 6. The replacement body is one element innewLines[], joined with\n. There is no path through this code that emits a mid-line split.What this rules in
Four variants reproducing in your live chain, against a source that demonstrably produces clean output for the same inputs, points at a runtime layer between or after the MCP transport and the file on disk. Three candidates ordered by my prior:
(a) Linter (or any auto-format) plugin reacting to
app.vault.modify(). You flagged in #76 that "Linter normalises spacing on UI save".app.vault.modify()fires Obsidian'svault.on('modify', …)event — if Linter (or another plugin) is registered there with a re-format pass, your re-read after the patch could be observing the post-format state, not the post-applyPatchstate. The fact that all four variants reproduce — including plain prose with no table-cell or code-span context — is consistent with a post-process pass that operates on rendered markdown rather than on a specific syntax shape.(b) File-on-disk encoding mismatch with the described fixture. CRLF, BOM, NBSP, trailing whitespace can shift what
app.vault.read()returns relative to what an editor renders.(c)
mcp-remote/ Cowork chain mutating thecontentargument in transit. JSON-RPC is preservative on paper but the payload passes through three runtimes here.What unblocks the next step
If any of these are quick on your side, in any order, they localize the layer:
- List active plugins:
cat .obsidian/community-plugins.jsonof the test vault. Linter, Templater-on-save, "Format on save"-style plugins are the prime suspects. - Repro variant C with Linter (and any other auto-formatter) disabled, leaving the connector + Local REST API only. Same fixture, same call. If clean → (a) confirmed.
- Repro variant C via MCP Inspector instead of Cowork +
mcp-remote(http://127.0.0.1:27200/mcpwith the bearer from the plugin settings). Same fixture, same arguments. If clean → (c) confirmed. - Hex-dump the fixture before patching:
xxd Tests/fixture-c.md | head -20. Rules out BOM/CRLF.
Happy to ship a debug build instrumented around
vault.on('modify')if a Linter-style culprit is suspected and you want a labelled trace instead of a binary search through the plugin list.Source-side stack is exhaustively verified at this point; one more bit of runtime evidence localizes the layer. — istefox
@folotp — reading back, I shorted the offer-acknowledgement in my previous comment. Worth correcting explicitly because the engagement shape you're proposing is exactly what unlocks short cycle times on edge-case bugs like this one.
Accepting all three points:
-
Debug build with stack traces / debug logs around the boundary scan: yes. If your four disambig steps land on (a) Linter or (c)
mcp-remotechain mutation, no debug build needed — it's a runtime layer, ship-as-is plus a caveat doc. If they don't unblock, I'll instrumentpatchHelpers.ts:442–448directly: log the regex input string, the per-linelines[i], the index where the boundary scan terminates, and the section-body slice that gets handed to the splice. Targeted patch on a labelled BRAT-pinned build. That should produce the byte-precise gap between unit-test trace and live trace in one round. -
Verify all four variants on the live path before the
0.4.2cut: yes, this is the cycle I want for any patch that ships in response to your data. The0.4.0-beta.3→0.4.1cycle worked exactly that way for ToolPipe MCP Server: 120+ developer tools for Obsidian MCP Tools #76 (cosmetic blank-line) — you retested before the tag. Same for the next cut: I'll BRAT-pin a candidate, you re-run A/B/C/D, I tag only after green. -
Additional variants on request (
##at column 1 of a fenced code block,##inside<!-- HTML comment -->, multi-byte chars before the##): yes. The three you listed are all plausible edge cases for any regex with even subtle anchor sloppiness. If the disambig points at the source-side after all (rather than a runtime layer), I'll have a candidate fix to patch and a fixture set that includes those three on top of A–D. If it points at runtime, those variants are useful as a regression sentinel for whatever fix lands.
Default order on my side: wait for your runtime evidence (the four disambig steps), then either close as runtime / not-a-bug-in-source with a caveat doc, or ship instrumented build → fix → re-soak with the expanded fixture set. — istefox
-
- added a commit that references this issue
on May 4, 2026 @istefox — found the disconnect, and it explains everything cleanly. Apologies for the wasted iterations; you've been right on every substantive point since the April 24 reply.
The path mismatch
My retests this whole thread have been going through the legacy 0.3.x stdio chain, not the 0.4.x HTTP-embedded path. Concretely:
Client → stdio → ~/Library/Application Support/obsidian-mcp-tools/bin/mcp-server (upstream 0.3.x binary) → HTTPS:27124 → obsidian-local-rest-api (Coddington) → markdown-patchapiExtensions[0].version = "0.4.1"fromget_server_infomade me think I was on 0.4.x — but that's the plugin version exposed via Local REST API's manifest endpoint. My client was talking to Local REST API, which happened to have the 0.4.1 fork registered as an extension. The actual MCP path never touched your in-processapplyPatchinpatchHelpers.ts:442–448.Diagnostics from earlier today on my end:
lsof -iTCP -sTCP:LISTENshowed Obsidian listening on both127.0.0.1:27200(your HTTP-embedded server) and127.0.0.1:27124(Local REST API), so both were running side by side.ps auxshowed two liveobsidian-mcp-tools/bin/mcp-serverprocesses — the legacy 0.3.x upstream stdio binary, still resident from a pre-fork install.claude_desktop_config.jsonhad a singlemcpServers.obsidian-mcp-toolsentry pointing at that binary withOBSIDIAN_API_KEY(Local REST API's key), nomcp-remoteinvocation, no bearer token.- No
mcp-remoteprocess anywhere.
So my client config was unchanged from a pre-fork state. The client was talking to Local REST API via the legacy binary, and the corruption I documented was the markdown-patch bug — exactly the one you filed at coddingtonbear/markdown-patch#10.
After migration to the HTTP-embedded transport
Just rewrote
claude_desktop_config.jsonto usenpx -y mcp-remote http://127.0.0.1:27200/mcp --header 'Authorization: Bearer <token>', restarted the client, reran variant C against a fresh fixture verified clean on disk viaxxd:Pre-patch fixture (
xxd-verified):# Root ## Journal Original content. This refers to ## Links below. ## Links Original links content.Same call as before:
patch_vault_filewithtarget: "Root::Journal",targetType: "heading",operation: "replace",createTargetIfMissing: false,content: "REPLACEMENT.\n".Result on the HTTP-embedded path (
xxd-verified post-patch):# Root ## Journal REPLACEMENT. ## Links Original links content.Clean. Section boundary correctly landed on the real
## Linksheading. The##substring insideOriginal content. This refers to ## Links below.was correctly treated as section content (replaced), not as a boundary marker. Bonus: paragraph blank-line spacing aroundREPLACEMENT.is preserved (the legacy chain stripped it — separate but related markdown-patch quirk).So your 2026-05-04 trace of
patchHelpers.ts:442–448was for the right code; my retest just wasn't exercising it.What this means for the issue
For your fork, the bug is fixed by construction — your design Goal 4 ("Full bypass of Local REST API") sidesteps the markdown-patch boundary scanner entirely. Anyone who's already migrated to the 0.4.x HTTP-embedded transport doesn't see this.
For the upstream library, the bug is still real. The variant matrix from my earlier comment is genuine new diagnostic data on
markdown-patch's behavior — particularly variant C (plain prose, no code-span, no table) which rules out table-cell context and code-span context as preconditions. The boundary scanner appears to do a string-level match without an effective line-anchor on the live Local REST API path, even though the regex literal is/^(#{1,6})\s/. Worth forwarding to coddingtonbear/markdown-patch#10 if it isn't there already; happy to repost it on that thread directly if you'd prefer.Suggestion: close this issue
This thread is on
jacksteamdev/obsidian-mcp-tools(dormant upstream). The bug it documents is now traced to its actual home (markdown-patch) and tracked there. Your fork sidesteps it. I'd suggest closing #83 with a pointer to coddingtonbear/markdown-patch#10 and a note that 0.4.x of the fork is the upgrade path for users who hit this on the legacy binary.Thanks for the patience with the round trips. — PA
@folotp — separate thanks worth saying out loud rather than folded into the technical reply: the offer to act as a remote test bench is genuinely load-bearing for this kind of project. Real vaults produce edge cases that no unit-test fixture set will surface — long pilot notes, accumulated frontmatter quirks, mixed clients in the chain (Claude Desktop, Cowork, Cursor,
mcp-remote, Inspector), Linter rules and other auto-format plugins layered on top. The shape of the bug class for an MCP server that talks to Obsidian is exactly the shape that needs a real user with a real vault running real workflows, repeating against successive BRAT-pinned cuts, to surface clean.Three soak rounds in, you've already shipped the ship-quality multiplier the project needed: each cycle (
beta.1→beta.2→beta.3→0.4.0→0.4.1) closed with a folotp-verified delta that wouldn't have been caught at unit-test level. The variant matrix in this thread is more of the same, just sharper. The project keeps the bar where it is because of this kind of engagement — worth saying explicitly rather than implying it through technical follow-ups. — istefox- added a commit that references this issue
on May 4, 2026 @folotp — this is the kind of debug-deep that actually closes loops. The "wasted iterations" framing is too harsh on yourself: a systematic walk through
lsof/ps aux/ config inspection is exactly what surfaces a disconnect like "legacy0.3.xbinary still resident from a pre-fork install". That's a migration gotcha that no plugin-side check could have caught — the only signal of the legacy path is on the client-config side, which the fork'sget_server_infocan't see. Naming the disconnect cleanly is the win, not the round trips.Confirming the diagnosis
Everything you traced lines up:
apiExtensions[0].version = "0.4.1"is the plugin-side manifest LRA reads frommcp-tools-istefox's registration — purely a label attached to the LRA extension list. It says "this plugin is loaded" but nothing about which MCP path the client is actually exercising.- The legacy
~/Library/Application Support/obsidian-mcp-tools/bin/mcp-serverstdio binary remains resident on any vault that was on0.3.xupstream before the fork migration, unless the user explicitly removed it. The fork's0.4.0migration modal offers to delete it as step 2, but only if accepted at first-load — opt-in, dismissible, and silent if dismissed once. - Your post-migration retest with
mcp-remote→http://127.0.0.1:27200/mcpproduces clean output on variant C against thexxd-verified fixture. That's the canonical evidence thatpatchHelpers.ts:442–448works as intended on the live HTTP-embedded path. Closes the source-side question.
Variant matrix forwarding
You already did it —
coddingtonbear/markdown-patch#10now has your matrix from a minute before this reply. Perfect handoff: nothing for me to do downstream, themarkdown-patchmaintainer has the diagnostic data isolated from the wrapping clients.For the record,
markdown-patch#10is mine from2026-04-24(the original report cited by this thread). With your variant C contribution, it now has both the table-cell-with-code-span case and the plain-prose case — rules out the obvious framing-around-syntax-shape candidates.Closing #83
Agreed. Closing this thread with a pointer to
coddingtonbear/markdown-patch#10is the right shape — the bug doesn't live inobsidian-mcp-tools, the upstream repo here is dormant, and the fork sidesteps it architecturally. As OP you can close it directly with your own pointer-and-note; happy for me to add a short summary comment first if you'd prefer. Either works.Migration gotcha worth documenting
The legacy-binary-still-resident scenario will bite anyone who BRAT-pinned the fork on top of an existing
0.3.xupstream install and dismissed the migration modal at first load. Three concrete improvements the fork can make on the back of your debug, none of them blockers:- README migration section — add a "verify the legacy binary is gone" step (
ls ~/Library/Application Support/obsidian-mcp-tools/bin/mcp-servershould return ENOENT, with the equivalent paths for Linux / Windows). - Plugin first-load detector — currently surfaces the migration modal only if a legacy path is detected; could also log a diagnostic notice on every load while the path exists, so a dismissed modal doesn't go silent indefinitely.
get_server_infofield — include the local listen address (127.0.0.1:27200/mcp) so future debug sessions can disambiguate "plugin loaded" from "client routed to plugin" with one tool call.
I'll capture these on the fork tracker as a single backlog issue. None are urgent for closing this thread; future users hit it with the README step instead of having to bisect with
lsof.Thanks for the systematic debug, the honest admission, and the variant matrix that lands cleanly on the actual culprit's tracker. This is the engagement shape the project needs, full stop. — istefox
- added a commit that references this issue
on May 4, 2026 @istefox Thanks for the great work on this plugin and exemplary responsiveness. It's a pleasure to work with you on this!
@folotp — likewise, on both counts. Three soak rounds of the kind you've put in have done concrete work on three separate trackers from this thread alone, including narrowing the actual
markdown-patchbug surface for a fix downstream. That kind of cycle is the multiplier — project responsiveness matters less without the kind of report quality that exposes the disconnect-not-the-symptom. This thread is the canonical example of the engagement shape the project will keep building on.Whenever it's convenient on your side, the close on #83 with a pointer to
coddingtonbear/markdown-patch#10would tidy the public record. — istefoxUpdate for thread watchers —
markdown-patch v0.4.5(npm, published 2026-05-06 02:52Z) lands the boundary-scanner fix for the trigger originally reported here. Closure note from @coddingtonbear onmarkdown-patch#10:A fix for this was released as part of v0.4.5; thanks for the thorough test cases!
Credit for the "thorough test cases" goes to @folotp, whose variant matrix forwarded from this thread narrowed the trigger surface from the canonical table-cell-with-code-span case to four variants (table+code-span / single-row / plain-prose / code-span-no-table). Variant C (plain prose, no table) was the decisive case that scoped the fix to the boundary scanner generically rather than the cell-specific shape.
Propagation paths:
- Legacy
0.3.xstdio chain (Local REST API +markdown-patch): fix lands transparently once LRA cuts a release that bumps itsmarkdown-patchdep. Out of our hands; gated on the LRA release cadence. - HTTP-embedded
0.4.xline (istefox/obsidian-mcp-connector): bypassesmarkdown-patchentirely by design (Goal 4 of the architecture pivot). The equivalent surface is covered independently by0.4.2'shasParentH1+isInsideTableOrFencedCodeguards inpatchHelpers.ts.
@folotp — once convenient on your side, closing this with a pointer to
markdown-patch#10would tidy the public record (echoing my earlier nudge, fix now actually landed). Thanks again for the report quality that pulled the whole loop together.— istefox
- Legacy
Summary
When
patch_vault_fileis called withoperation: "replace"andtargetType: "heading"on a section whose body contains a markdown table, and one of the table cells contains a code span that looks like a heading (e.g.`## Other section`or`# Foo`), the replace operation does not anchor the section's end-boundary correctly. A fragment of the old cell content escapes the table and is rendered as an orphan## …heading after the operation. Callingreplacea second time to clean up the orphan duplicates the fragment instead of removing it.This is distinct from #71 (universal silent append-EOF on
targetType: "heading"due to leaf-only target lookup): the corruption fires inside the existing section structure rather than appending a new section at EOF, and the visible signature (orphan H2 from cell content, deduplication failure on retry) is consistent with a section-boundary scan that treats^##\slines as boundaries without respecting table or code-span syntactic context.Environment
obsidian-mcp-tools0.2.27obsidian-local-rest-api3.6.1Reproduction (minimal fixture)
Note
fixture-table-codespan.md:Call:
Observed
"File patched successfully"(no error).## Journalsection is partially modified, but a fragment of the old cell content — including the literal`## Links`text — escapes the table and appears as an orphan## LinksH2 heading at the position where the table used to end.## Linkssection that already existed below remains in place, so the file now has two## Linksheadings.replacea second time on## Journalto clean up: instead of removing the orphan, the fragment is duplicated. The orphan## Linksheading appears a second time. Each subsequent retry compounds the duplication.The only way to recover the file is a full rewrite via
create_vault_file.Expected
## Journalsection content (between## Journaland the next sibling heading## Links) is fully replaced by the suppliedcontent.`## Links`code span is not interpreted as a section boundary.Root-cause hypothesis
The end-boundary detection of a section appears to scan the body line-by-line and match any line satisfying
^#{1,6}\sas a sibling-or-ancestor heading boundary, without respecting:|...|table cell is a cell value, not block-level markdown.Once the boundary is detected too early (at the
`## Links`code-span line within the cell), the rewrite truncates the section before the table actually ends. The trailing portion of the cell content — everything between the false boundary and the real next heading — is left in the file as an orphan, and gets re-promoted to H2 on the next render because the surrounding table structure is no longer well-formed.The duplication-on-retry is consistent with the same logic applied to an already-corrupted file: the retry's boundary scan now matches both the original
`## Links`(still present in the body) and the orphan## Linksfrom the previous failed pass.A proper fix probably lives upstream in
markdown-patch(the section parser used by Local REST API), but the wrapper could mitigate by validating that the resolved section boundary is not inside a fenced code block / code span / table cell before issuing the replace.Workaround
For any section containing a markdown table with code-span heading references:
patch_vault_file replaceon that section entirely. Usecreate_vault_fileto rewrite the whole note instead. Acceptable cost on small notes; for larger notes a two-step pattern (create_vault_filechunk 1 withoverwrite, thenappend_to_vault_filechunk 2) works.`## Foo`code spans inside table cells if you anticipate the section will be patched later. Reformulate as e.g. "theFoosection".replace— it will compound. Skip directly tocreate_vault_file.Related
targetType: "heading"surface. Different symptom (universal silent append-EOF when target lookup fails) but the wrapper fix in PR fix(local-rest-api): replace hardcoded Create-Target-If-Missing with agent-controlled option #72 (Create-Target-If-Missing: false) does not address this boundary-detection bug — these are orthogonal failure modes. Worth re-testing this fixture once fix(local-rest-api): replace hardcoded Create-Target-If-Missing with agent-controlled option #72 lands, in case the fix happens to short-circuit the boundary scan as a side-effect.get_vault_file(format: "json")fails Zod validation on any note with array-valued frontmatter (aliases,tags,up, …) #81 — unrelated code path (Zod schema forget_vault_file format: "json") but same general pattern of wrapper-level handling that diverges from what the plugin actually accepts.coddingtonbear/markdown-patch— likely the actual home of the section-boundary parsing logic. If maintainer-side investigation confirms the parser is the culprit, an upstream issue there is the right next step.Happy to provide additional fixtures or test against PR #72 once it lands.