Skip to content
This repository was archived by the owner on May 13, 2026. It is now read-only.
This repository was archived by the owner on May 13, 2026. It is now read-only.

[Bug] patch_vault_file replace corrupts sections containing markdown tables with inline code-span heading refs #83

Description

@folotp

Summary

When patch_vault_file is called with operation: "replace" and targetType: "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. Calling replace a 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 ^##\s lines as boundaries without respecting table or code-span syntactic context.

Environment

  • obsidian-mcp-tools 0.2.27
  • obsidian-local-rest-api 3.6.1
  • Obsidian 1.12.7
  • macOS Tahoe 26.4.1, Apple Silicon
  • Consumed via an MCP client

Reproduction (minimal fixture)

Note fixture-table-codespan.md:

---
title: fixture-table-codespan
---

## Journal

| Date       | Event                                                                                  |
| ---------- | -------------------------------------------------------------------------------------- |
| 2026-01-01 | Initial entry. Discusses the `## Links` section below and how `patch_vault_file` anchors section boundaries. |

## Links

- Parent

Call:

await mcp.call("patch_vault_file", {
  filename: "fixture-table-codespan.md",
  operation: "replace",
  targetType: "heading",
  target: "Journal",
  content: "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n"
});

Observed

  1. Response: "File patched successfully" (no error).
  2. The original ## Journal section is partially modified, but a fragment of the old cell content — including the literal `## Links` text — escapes the table and appears as an orphan ## Links H2 heading at the position where the table used to end.
  3. The legitimate ## Links section that already existed below remains in place, so the file now has two ## Links headings.
  4. Calling replace a second time on ## Journal to clean up: instead of removing the orphan, the fragment is duplicated. The orphan ## Links heading 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

  • ## Journal section content (between ## Journal and the next sibling heading ## Links) is fully replaced by the supplied content.
  • The body of the table cell is treated as opaque text — the `## Links` code span is not interpreted as a section boundary.
  • Repeated calls converge to the same final state, not duplicate.

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}\s as a sibling-or-ancestor heading boundary, without respecting:

  • Code-span context: text between backticks within a paragraph or table cell is not a real heading. A markdown-aware tokenizer (remark, marked, markdown-it) maintains this context; a regex-based boundary scan does not.
  • Table-row context: text inside a |...| 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 ## Links from 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:

  • Avoid patch_vault_file replace on that section entirely. Use create_vault_file to rewrite the whole note instead. Acceptable cost on small notes; for larger notes a two-step pattern (create_vault_file chunk 1 with overwrite, then append_to_vault_file chunk 2) works.
  • Avoid writing `## Foo` code spans inside table cells if you anticipate the section will be patched later. Reformulate as e.g. "the Foo section".
  • If the file is already corrupted, do not retry replace — it will compound. Skip directly to create_vault_file.

Related

Happy to provide additional fixtures or test against PR #72 once it lands.

Activity

  1. istefox commented on Apr 24, 2026

    @istefox

    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-connector at v0.3.5) and it's logically orthogonal to #71 and #78.

    Your root-cause hypothesis matches mine: the section-boundary scan in markdown-patch is regex-based on ^#{1,6}\s without 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 (via obsidian-local-rest-api's PATCH /vault/{filename} handler). A wrapper-side fix would be duplicating part of the parser. Worth opening an issue on markdown-patch with 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 if markdown-patch stays 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 with create_vault_file (overwrite). This sidesteps markdown-patch entirely at the cost of a round trip.

    I'll open the upstream issue on markdown-patch in 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).

  2. istefox commented on Apr 24, 2026

    @istefox

    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.

  3. istefox commented on May 4, 2026

    @istefox

    Circling back since #79 means this thread is unmaintained on upstream. The community fork istefox/obsidian-mcp-connector 0.4.0 (released 2026-05-04) doesn't go through markdown-patch: the in-process plugin has its own applyPatch in patchHelpers.ts:442–448 that uses a line-start regex ^(#{1,6})\s for 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}\s doesn't match it. The scan correctly identifies the real ## Links heading as the section boundary. Replace then slices whole lines between ## Journal and ## 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 on coddingtonbear/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

  4. folotp commented on May 4, 2026

    @folotp
    Author

    @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.md from 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 ## Journal ends. It encounters | ref | `## Links` | and matches ## Links inside 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 ## Links heading 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 a g flag.

  5. istefox commented on May 4, 2026

    @istefox

    @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-embedded HEAD 2387e0e (post-0.4.1):

    I copied your fixture verbatim into a unit test calling patchVaultFileHandler with operation: "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-448 runs lines[i].match(/^(#{1,6})\s/) per individual line; lines[i] is one element from rawContent.split("\n"), so ^ anchors to position 0 of that single-line string. The table row | ref | `## Links` | starts with |, the regex returns null, and the scan correctly identifies the real ## Links heading at line index 11 as the boundary.

    Possible explanations for the disconnect:

    1. BRAT cached an older version. Could you call get_server_info and post the apiExtensions[0].version field you actually ran against? On 0.4.1 it should report apiExtensions[0].version = "0.4.1". If it shows 0.4.0 or earlier, BRAT didn't pick up the update on this round (the heading-blank fix in 0.4.1 is unrelated to this bug, but the version pin matters for the trace below).

    2. Replacement content shape differs from the paraphrase. Your reply describes content as [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?

    3. 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, exact target) 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 cut 0.4.2 immediately.

    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

  6. folotp commented on May 4, 2026

    @folotp
    Author

    @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_info returns:

    "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.md
    • target: "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\n heading in content.

    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) and targetType: "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) Journal yes ## Links` |
    B Single-row table code-span in table cell, 1 data row Root::Journal yes ## Links` |
    C Inline plain prose, no table, no code-span line: Original content. This refers to ## Links below. Root::Journal yes ## Links below.
    D Code-span in paragraph, no table line: Original content. See the `## Links` section below. Root::Journal yes ## 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/g applied to the section body as a single string with no m flag — ^ only anchors to position 0 of the whole string, and g lets it walk forward, matching ## anywhere mid-string;
    • the input to the regex is not pre-split on \n on the live path (whatever code does rawContent.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 ## Links heading. 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 ## Links heading 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 same patchVaultFileHandler. Worth checking whether there's a wrapping layer between the MCP HTTP route and patchHelpers.ts:442–448 that 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
  7. istefox commented on May 4, 2026

    @istefox

    @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-embedded HEAD (= 0.4.1 source)

    git diff 30ef3c9..HEAD over packages/obsidian-plugin/src is empty — the 0.4.1 tag 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 in patchHelpers.ts, all with explicit ^ anchor applied to per-line elements after rawContent.split("\n"):

    • patchHelpers.ts:40 — resolveHeadingPath leaf scan
    • patchHelpers.ts:417 — leaf-name match in the heading branch of applyPatch
    • patchHelpers.ts:443 — sibling-or-higher boundary scan in applyPatch

    Grep over packages/obsidian-plugin/src for any other heading scanner returns nothing. There is no compat shim for PATCH /vault/, no apiExtension layer between the MCP HTTP route and patchVaultFileHandler, and patchVaultFileHandler delegates straight to applyPatch. The MCP HTTP path is the unit-test path.

    Bundle integrity check (your "different scanner" hypothesis)

    I rebuilt main.js from HEAD source and diffed against the shipped 0.4.1 artifact:

    • size delta: 6 bytes
    • sha256: differ
    • cmp -l shows divergence starting at offset 1633466
    • the divergent bytes are __dirname/__filename strings inside onnxruntime-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/) walks i=3..6; at i=4 the line is Original content. This refers to ## Links below. and the ^ anchors to position 0 (O), so the regex returns null; i=6 matches at ## Links, sectionEnd = 6. The replacement body is one element in newLines[], 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's vault.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-applyPatch state. 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 the content argument 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:

    1. List active plugins: cat .obsidian/community-plugins.json of the test vault. Linter, Templater-on-save, "Format on save"-style plugins are the prime suspects.
    2. 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.
    3. Repro variant C via MCP Inspector instead of Cowork + mcp-remote (http://127.0.0.1:27200/mcp with the bearer from the plugin settings). Same fixture, same arguments. If clean → (c) confirmed.
    4. 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

  8. added 2 commits that reference this issue on May 4, 2026
  9. istefox commented on May 4, 2026

    @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:

    1. Debug build with stack traces / debug logs around the boundary scan: yes. If your four disambig steps land on (a) Linter or (c) mcp-remote chain mutation, no debug build needed — it's a runtime layer, ship-as-is plus a caveat doc. If they don't unblock, I'll instrument patchHelpers.ts:442–448 directly: log the regex input string, the per-line lines[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.

    2. Verify all four variants on the live path before the 0.4.2 cut: yes, this is the cycle I want for any patch that ships in response to your data. The 0.4.0-beta.3 → 0.4.1 cycle 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.

    3. 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

  10. added a commit that references this issue on May 4, 2026
  11. folotp commented on May 4, 2026

    @folotp
    Author

    @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-patch
    

    apiExtensions[0].version = "0.4.1" from get_server_info made 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-process applyPatch in patchHelpers.ts:442–448.

    Diagnostics from earlier today on my end:

    • lsof -iTCP -sTCP:LISTEN showed Obsidian listening on both 127.0.0.1:27200 (your HTTP-embedded server) and 127.0.0.1:27124 (Local REST API), so both were running side by side.
    • ps aux showed two live obsidian-mcp-tools/bin/mcp-server processes — the legacy 0.3.x upstream stdio binary, still resident from a pre-fork install.
    • claude_desktop_config.json had a single mcpServers.obsidian-mcp-tools entry pointing at that binary with OBSIDIAN_API_KEY (Local REST API's key), no mcp-remote invocation, no bearer token.
    • No mcp-remote process 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.json to use npx -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 via xxd:

    Pre-patch fixture (xxd-verified):

    # Root
    
    ## Journal
    
    Original content. This refers to ## Links below.
    
    ## Links
    
    Original links content.
    

    Same call as before: patch_vault_file with target: "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 ## Links heading. The ## substring inside Original content. This refers to ## Links below. was correctly treated as section content (replaced), not as a boundary marker. Bonus: paragraph blank-line spacing around REPLACEMENT. is preserved (the legacy chain stripped it — separate but related markdown-patch quirk).

    So your 2026-05-04 trace of patchHelpers.ts:442–448 was 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

  12. istefox commented on May 4, 2026

    @istefox

    @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

  13. added a commit that references this issue on May 4, 2026
  14. istefox commented on May 4, 2026

    @istefox

    @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 "legacy 0.3.x binary 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's get_server_info can'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 from mcp-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-server stdio binary remains resident on any vault that was on 0.3.x upstream before the fork migration, unless the user explicitly removed it. The fork's 0.4.0 migration 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/mcp produces clean output on variant C against the xxd-verified fixture. That's the canonical evidence that patchHelpers.ts:442–448 works as intended on the live HTTP-embedded path. Closes the source-side question.

    Variant matrix forwarding

    You already did it — coddingtonbear/markdown-patch#10 now has your matrix from a minute before this reply. Perfect handoff: nothing for me to do downstream, the markdown-patch maintainer has the diagnostic data isolated from the wrapping clients.

    For the record, markdown-patch#10 is mine from 2026-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#10 is the right shape — the bug doesn't live in obsidian-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.x upstream 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:

    1. README migration section — add a "verify the legacy binary is gone" step (ls ~/Library/Application Support/obsidian-mcp-tools/bin/mcp-server should return ENOENT, with the equivalent paths for Linux / Windows).
    2. 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.
    3. get_server_info field — 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

  15. added a commit that references this issue on May 4, 2026
  16. folotp commented on May 4, 2026

    @folotp
    Author

    @istefox Thanks for the great work on this plugin and exemplary responsiveness. It's a pleasure to work with you on this!

  17. istefox commented on May 4, 2026

    @istefox

    @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-patch bug 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#10 would tidy the public record. — istefox

  18. added 3 commits that reference this issue on May 4, 2026
  19. istefox commented on May 6, 2026

    @istefox

    Update 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 on markdown-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.x stdio chain (Local REST API + markdown-patch): fix lands transparently once LRA cuts a release that bumps its markdown-patch dep. Out of our hands; gated on the LRA release cadence.
    • HTTP-embedded 0.4.x line (istefox/obsidian-mcp-connector): bypasses markdown-patch entirely by design (Goal 4 of the architecture pivot). The equivalent surface is covered independently by 0.4.2's hasParentH1 + isInsideTableOrFencedCode guards in patchHelpers.ts.

    @folotp — once convenient on your side, closing this with a pointer to markdown-patch#10 would 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions