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
- Response:
"File patched successfully" (no error).
- 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.
- The legitimate
## Links section that already existed below remains in place, so the file now has two ## Links headings.
- 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.
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.