SIGN IN SIGN UP

Fix MCP vault_patch storing application/json content as a quoted string (#268)

* Parse JSON-encoded string content in MCP vault_patch

MCP clients (LLMs) routinely pass `content` for an application/json
patch as a JSON-encoded string (e.g. "[\"a\",\"b\"]", "null") rather
than a native value. The handler stored it verbatim, so markdown-patch's
yaml.stringify wrote a quoted scalar ('["a","b"]', "null") instead of a
real YAML sequence or null.

Mirror what express.json already does on the REST path: when
contentType is application/json and content is a string, JSON.parse it
into a native value before patching, and reject malformed JSON. Native
(non-string) content is passed through unchanged. Applies to all target
types, matching express's content-type-driven parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Validate contentType against allowed enum in MCP vault_patch

The REST path rejects unknown MIME types via isContentType before
calling patchFileSection. The MCP path declared contentType as an
unconstrained z.string(), so any value passed through. An unrecognised
MIME type on a heading or block target makes markdown-patch's
replace/prepend/append switch fall through and return undefined, which
patchFileSection would then write to the vault.

Constrain the MCP schema to the same enum the REST path enforces so
invalid content types are rejected at the tool boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Resolve contentType default once in MCP vault_patch

The default 'text/markdown' was applied twice: once in the JSON-parse
guard and again in the patchFileSection argument. Hoist it into a single
resolvedContentType variable so the guard and the call can never diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Refuse to write a non-string applyPatch result in patchFileSection

markdown-patch's replace/prepend/append switch on contentType with no
default branch, so an unrecognised contentType on a heading or block
target returns undefined. Writing that would corrupt the file.

Add a defensive backstop in patchFileSection that throws instead of
writing a non-string result. The JSON-encoded-string coercion stays in
the MCP handler: it compensates for MCP delivering content as a
JSON-encoded string, whereas the REST path's express.json (strict:false)
already decodes bodies, so moving the parse down would double-decode and
break application/json string values on the REST path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Document that rejectIfContentPreexists applies only to string content

markdown-patch's preexistence check is gated on
typeof instruction.content === 'string', so it never fires for
application/json content (e.g. table rows on heading/block targets),
which is always a non-string array. Note this limitation in the MCP
tool description and the OpenAPI parameter description so callers don't
expect idempotency for application/json patches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove caveat from rejectIfContentPreexists docs

The prior commit documented that rejectIfContentPreexists has no effect
on application/json content as a way to be accurate about the current
limitation. Instead, document the intended behaviour — the caveat belongs
in the upstream markdown-patch bug (coddingtonbear/markdown-patch#13),
not in user-facing docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove unreachable guard against applyPatch returning non-string

The typeof patched !== "string" check and its test defended against
markdown-patch's replace/prepend/append returning undefined for an
unrecognised contentType (incomplete switch, no default branch). Both
the MCP path (z.enum constraint) and REST path (isContentType guard)
already reject unknown contentTypes before reaching patchFileSection,
making the guard unreachable in practice.

The underlying markdown-patch issue is tracked upstream:
coddingtonbear/markdown-patch#14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove redundant comment from MCP integration test

The test name already describes what it's verifying; the comment
restated it while also adding noise about the assertion mechanism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Use ContentType enum from markdown-patch instead of string literals

Replaces the hardcoded "text/markdown" / "application/json" strings in
the vault_patch schema and handler with the ContentType enum already
used on the REST path via isContentType. z.nativeEnum(ContentType) acts
as both the Zod validator and the single source of truth for the type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Simplify comment explaining the MCP JSON parse step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Type vault_patch content as z.string() in MCP tool

MCP transport delivers all tool parameters as strings regardless of the
declared schema type; z.unknown() gave no useful type signal and the
description was misleading in suggesting native arrays were acceptable.

z.string() accurately reflects what the transport delivers, lets TypeScript
enforce the contract, and removes the now-redundant typeof guard from the
JSON-parse branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Adam Coddington <me@adamcoddington.net>
P
Piotr committed
899f116ff5daa76f6757b95eb8810ce3fa5219fb
Parent: 79dd2c6
Committed by GitHub <noreply@github.com> on 7/10/2026, 2:30:46 AM