Markdown Claude Review

Google-Docs-style comment threads on markdown in VS Code, wired back to Claude Code
over MCP.
Claude writes the document. You read it rendered, select a passage, and comment — the
thread appears as a bubble in the margin beside it. Claude reads your comments, edits
the document, replies where it needs clarification, and resolves what it addressed.
Everything is local files.
The loop
- Claude Code writes
docs/strategy.md.
- You open the review preview (
Ctrl+K V), select a passage, and comment on it.
- You press the send button, which types
/markdown-review docs/strategy.md into
Claude Code. Or just say "address my comments on the strategy doc".
- Claude calls
list_threads for that document, edits it, then resolve_thread (done)
or reply_thread (needs your input). Its replies appear in the bubble.
- Threads you have not resolved stay open. Nothing is ever silently dropped.
There is no hand-off file and nothing to paste. The threads live behind an MCP server,
so asking Claude for them in plain English is the whole interface; /markdown-review
is a shortcut, not a requirement.
Install
Nothing here is OS-specific: macOS, Linux and Windows all work. You need VS Code 1.90+,
Claude Code, and the code CLI on your PATH (on macOS, run Shell Command: Install
'code' command in PATH from the command palette first).
From the latest release,
no toolchain needed:
gh release download -R MagicMicky/markdown-claude-review -p '*.vsix'
code --install-extension markdown-claude-review.vsix
Re-run those two lines to upgrade. There is no auto-update: the extension is not on the
Marketplace, so VS Code has nowhere to check.
From source instead, with Node 18+:
npm install
npm run package # -> markdown-claude-review.vsix
code --install-extension markdown-claude-review.vsix
Then, in the workspace where you write documents:
- Run Markdown Review: Set Up Claude Code Integration from the command palette. It
writes
.mcp.json and a /markdown-review slash command into .claude/commands/.
- Restart Claude Code in that workspace and approve the
markdown-review server.
The command is /markdown-review, not /review. Claude Code has a built-in /review,
and a workspace that has not run setup does not get an unknown-command error from
/review — it silently gets a code review instead, which looks enough like the hand-off
working to be missed. If you set this up before v0.1.2, re-run the setup command: it
writes the new name and deletes the review.md the old version left shadowing Claude
Code's own. An explicit mdreview.sendPrompt in your settings is yours, so it is left
alone; update it by hand if it still says /review.
The generated .mcp.json holds absolute paths, so it is machine-specific — if you commit
it, everyone else re-runs the setup command on their own machine.
Both of those paths are in the extension's own storage, which is named after the
extension and nothing else, so neither a VS Code update nor an extension update moves
them. One is the server; the other is a small launcher that finds a Node to run it with
at the moment Claude Code starts it — your node if you have one, otherwise the one
inside whichever VS Code build is currently installed. Nothing about your machine is
recorded at setup time, so nothing recorded can go out of date.
If you set this up before v0.1.6, re-run the setup command — on macOS and Windows it
never worked at all, and the entry it wrote needs replacing.
The extension is disabled in Restricted Mode:
it reads comment threads from files in the workspace, and the hand-off types a
configurable line into a terminal. Trust the folder before using it.
Two surfaces
The review preview is where you comment. It renders the document — using VS Code's
own preview stylesheet, loaded from the built-in extension at runtime, so it looks like
the preview you already know — and puts comment bubbles in the right margin, aligned to
the passages they belong to.
The source editor shows the same threads as VS Code comment threads, collapsed to a
gutter marker and an underline so your prose never moves.
Both are projections of the same state. A reply typed in either appears in the other.
| Action |
How |
| Open the preview |
Ctrl+K V, or the preview icon in the editor title bar |
| Comment |
Select rendered text, click Comment, type, Ctrl+Enter |
| Comment from the source |
Select text, Ctrl+Alt+M, or the gutter + |
| Reply |
Click a bubble, type in its reply box, Ctrl+Enter |
| Resolve |
Resolve on the bubble |
| Jump to the source |
Alt+click a passage in the preview |
| Jump between comments |
Next / Previous Comment from the palette |
| Re-attach a stale comment |
Select the new passage, then Re-attach to selection on the bubble |
| Hand off to Claude |
The send button in the preview's title bar, or type /markdown-review in Claude Code |
| Read the closed history |
The eye button in the preview's title bar, or the thread in the source editor |
| Force a re-scan |
Markdown Review: Refresh Threads, if state ever looks stale |
The send button types the document you are looking at along with the prompt —
/markdown-review docs/strategy.md — so the review it triggers is that document's, not
the workspace's. Run it from the command palette with no preview or markdown editor
focused and it asks which document to send, offering the whole workspace as one of the
choices.
Beside it, the eye button lists resolved threads in the preview too. They come back
greyed, tinted green in the prose, and offering Reopen where a live thread offers
Resolve — it is a read-through of what was already decided, so nothing there can pull
a closed thread back open by accident. It is off by default because the preview is where
you work; the source editor's inline thread has always kept the full history regardless.
Three visual layers, deliberately distinct: every open thread tints its passage faintly;
a bar in the left margin tracks where the source editor's cursor is; and the thread you
are actually in gets a strong tint and an expanded bubble.
Ours replaces the built-in preview: it takes Ctrl+K V and Ctrl+Shift+V, and VS
Code's own preview buttons hide themselves so the title bar shows one preview icon
rather than three. The built-in preview is still on Markdown: Open Preview in the
command palette.
Set mdreview.replaceBuiltInPreview to false to reverse all of that in one go — the
built-in buttons and keybindings come back, and the review preview moves to
Markdown Review: Open Review Preview in the palette.
mdreview.inlineThreads controls the editor layer: collapsed (default), expanded
if you want threads to open in place, or off for preview-only.
Thread states
| State |
Meaning |
| open |
You commented; waiting on Claude |
| answered |
Claude replied or edited; waiting on you |
| resolved |
Closed. Kept forever as history |
| stale |
The text it pointed at no longer exists. Kept and shown, never deleted |
answered and resolved are not interchangeable. answered is whose-turn-it-is,
and it is set automatically: whoever posts the last message flips the thread to the
other side, so a Claude reply makes it answered and your reply back makes it open
again. The conversation is still live. resolved is the end of the thread's life, and
only ever happens on purpose — Claude calling resolve_thread after making the edit, or
you clicking Resolve.
A resolved thread disappears from the preview, which is the live review surface. It is
not gone: it stays in the review file and in the source editor's inline thread, where you
can reopen it — and the preview's eye button brings it back into view there too.
Claude only sees open and stale threads by default, so an answered thread is
not waiting on Claude and a resolved one is out of the loop entirely.
Comments store no line numbers. Claude rewrites files out-of-band, so anything
positional would be wrong by the time it is read back. Instead each thread keeps a
W3C-style text quote selector:
the exact quote, ~48 characters either side, and the heading trail it sat under.
On every save, anchors re-resolve through a cascade, most trustworthy first:
- Exact quote, unique in the document → exact
- Exact quote appearing several times → pick the occurrence whose surrounding text
agrees. Enough agreement → exact; little or none → drifted, because with
several identical passages the context is the only thing telling them apart
- Fuzzy paragraph match (Sørensen–Dice over character trigrams) within the original
section → drifted
- Fuzzy paragraph match document-wide, at a stricter threshold → drifted
- Nothing above threshold → stale
A comment pointing confidently at the wrong paragraph is worse than one that admits it
lost its place, so the cascade refuses to guess rather than always returning something.
Drifted threads keep showing the original wording, flagged as edited, so you can see
what Claude changed under your comment.
Tune the bar with mdreview.fuzzyThreshold (default 0.62).
How the preview knows what you selected
A comment anchors to source characters, but you select rendered text — and the two are
not the same string. Rather than reconstruct which characters were markup, the renderer
labels every run of rendered text with the source offsets it came from, taken from
markdown-it's own token stream.
Highlighting and selection are then arithmetic on those offsets. That is what makes a
comment work on a table cell, inside a fenced code block, on a snake_case identifier,
across an HTML entity, or spanning several paragraphs — all cases where guessing what
the page would say produced no highlight at all.
Rendering uses markdown-it with the built-in preview's own options and highlight.js
alias map, so output matches rather than merely resembles it. It is not pinned to the
version VS Code bundles: 12 and 15 render this repo's documents, and every edge case the
offset mapping depends on, to byte-identical HTML.
Storage
One JSON file per document, mirroring its path:
docs/strategy.md -> .review/docs/strategy.md.review.json
Plain, diffable, greppable, commit it or don't. Nothing else is written; Claude reads
threads through the MCP server, not through a generated hand-off file.
Two processes write these files — this extension and the MCP server — so both merge
against what is on disk immediately before writing, unioning messages and resolving the
rest by timestamp. Without that, whoever wrote second would silently revert the other:
your resolve overwriting Claude's reply, or the reverse.
A file that cannot be parsed is never written over. It is reported, and its threads are
left alone until you fix it — the sidecar is the only copy of that history, and treating
an unreadable file as an empty one would destroy it.
| Tool |
Purpose |
list_threads |
Comments on one document, or on the whole workspace when asked for; with neither, the documents that have comments, to choose from |
get_thread |
One thread with its full history |
reply_thread |
Ask a clarifying question or push back; leaves the thread open |
resolve_thread |
Close a thread after editing, with a note on what changed |
create_thread |
Flag an unverified claim in its own document and ask you about it |
The server also ships connect-time instructions telling Claude to reach for
list_threads whenever you mention comments or feedback on a document — so plain
English works without the slash command, and Claude will not go hunting for comments
inside the markdown.
Which document a review is about
A review is scoped to what you asked about, and list_threads makes Claude say which
that is. Three answers, none of them a fallback for the others:
|
|
document: "docs/strategy.md" |
That document's threads. The usual case, and the one the send button and a session already working on a document both produce. |
all_documents: true |
Every document in the workspace. A scope you ask for — "go through all my comments" — rather than one you arrive at. |
| neither |
The documents that have comments: their paths, their counts, and the headings the comments sit under. No threads. |
The third is what makes the other two honest. Left to mean "everything", an omitted
argument turns "address my comments" in a fresh session into a sweep of every
unfinished document you own; answered with the list instead, Claude either recognises
the document from the conversation, or shows you the list and asks. Two documents open
in two parallel sessions stay separate, because neither session can reach the other's
comments without you or the conversation naming that document.
Recognition is deliberately left to Claude, which has the conversation, rather than
done here from the files, which cannot see it. The candidate list carries no "most
recent" marker and is ordered by path: ranking it would invite picking the top entry
instead of the right one. The section headings are there so "the new firewall policy"
has something to match against.
A document that matches nothing comes back with the same list rather than a bare miss,
so a near-miss on a path costs one round trip — and the cheapest way out of a dead end
is never to widen the scope to everything.
The list is cheap enough not to think about: around 380 tokens for two documents, most of
that the fixed guidance on how to choose, and roughly 50 per document after. So the
round trip it costs when Claude could not tell which document you meant is worth far
less than the prose it saves when it guesses wrong.
What Claude is told to do
The goal, stated once in src/core/guidance.ts and rendered into the MCP instructions,
the list_threads result and the /markdown-review command so the three cannot drift:
Make the document correct and useful on its own terms. A reader who never saw the
review should not be able to tell that one happened.
Which means, generically — the same rules whether it is a compliance policy, a team
strategy or a set of goals:
- Nothing about the review goes in the document. No "updated per feedback", no
changelog lines, no TODOs, no HTML comments, no markers tying prose back to a comment.
- No reasoning in the prose. The document states what is true; it does not argue for
itself. Justification, findings and disagreement go in the thread reply.
- Questions are replies, not paragraphs. Neither answers to your questions nor
questions back to you belong in the text.
- Your comment is a pointer, not copy. It says what is wrong; Claude decides how the
prose should read, rather than transcribing your shorthand.
- Edits stay proportionate. No restructuring or polishing passages nobody commented
on. Something else looks wrong?
create_thread raises it instead of silently fixing it.
- Uncertainty stops the edit. If Claude cannot establish the truth it leaves the
passage alone and replies, rather than hedging or guessing in the document.
To change any of this, edit the lists in src/core/guidance.ts and rebuild — all three
prompts follow.
What Claude reads, and what that costs
A paragraph judged in isolation gets edited into something that contradicts or repeats
the one next to it — so Claude needs the surrounding document. Re-reading the whole file
on every review round is the expensive way to get that, so list_threads groups threads
by document and adapts:
| Document |
What comes back |
Instruction |
| Scope not stated |
Paths, counts and section headings. No threads, no prose |
Pick the document, or ask which |
| ≤ 400 lines |
Threads only |
Read the whole file once, before your first edit to it |
| > 400 lines |
Threads, plus the heading outline and the section_context around each commented passage |
Read further only where the change touches something stated elsewhere |
Short documents get no excerpts on purpose: shipping an outline and section bodies for a
file Claude is about to read in full would bill the same prose twice. Section bodies are
sent once per section, not once per thread, since several comments usually land in the
same one.
Measured on a 1041-line policy document with three comments (est. tokens, chars/4):
|
tokens |
| Reading the whole document |
21,369 |
list_threads — first call, everything included |
4,129 |
list_threads — subsequent calls |
3,437 |
The full editing contract is returned once per session; later calls carry only the goal
and the never in the document rules, which are the ones broken most often.
Thresholds live in src/core/context.ts (SHORT_DOCUMENT_LINES, MAX_SECTION_LINES).
Settings
| Setting |
Default |
|
mdreview.replaceBuiltInPreview |
true |
Hide VS Code's preview buttons and take Ctrl+K V |
mdreview.inlineThreads |
collapsed |
Editor layer: collapsed, expanded, off |
mdreview.highlightCommentedRanges |
true |
Underline commented passages in the editor |
mdreview.showResolvedInPreview |
false |
List closed threads in the preview as well |
mdreview.fuzzyThreshold |
0.62 |
Similarity needed to re-attach to rewritten prose |
mdreview.reviewDir |
.review |
Where threads are stored |
mdreview.author |
git user.name |
Name on your comments |
mdreview.terminalName |
claude |
Substring matching your Claude Code terminal |
mdreview.sendPrompt |
/markdown-review |
What Send Review types, before the document path |
The last four are machine-scoped on purpose: sendPrompt and terminalName decide what
gets typed into a terminal, so a cloned repository must not be able to set them.
The preview also honours VS Code's own markdown.preview.* settings — fontFamily,
fontSize, lineHeight, breaks, linkify, typographer, the scroll-sync pair — and
your markdown.styles, so it behaves like the built-in preview you have already
configured.
Development
npm run typecheck # root project and the webview project
npm test # core suite plus the webview's DOM tests
npm run watch # rebuild on change
npm run build # three bundles: extension, MCP server, preview webview
npm run scenarios # local only: drive a real Claude Code session against the tools
Press F5 and pick Run Extension (sandbox) to launch an Extension Development Host
already opened on sandbox/, which is gitignored and yours to scribble in.
Tests run as two tsc projects. Most of them are plain node:test over src/core. The
webview's DOM mapping is tested separately with jsdom against real renderer output,
because that layer is where the bugs have actually been, and the DOM lib must not leak
into src/core.
See CLAUDE.md for the invariants worth not breaking.
Scenario trials
npm run scenarios tests the one thing a unit test cannot reach: whether Claude,
reading the tool descriptions and the generated slash command, actually says which
document it is reviewing. Each scenario builds a throwaway workspace holding two
unrelated documents with open comments, launches the real MCP server over stdio, and
drives a real claude -p session against it. What it asserts is the calls that were
made and the files that changed — not the wording of the reply.
| Scenario |
Expected call shape |
named-path — the UI hand-off types a path |
document=… |
session-context — two turns, the first about one document |
document=…, no lookup |
described-document — "my comments on the firewall policy" |
unscoped → document=… |
no-context — "review my comments", cold |
unscoped, then a question, no edits |
explicit-sweep — "all my comments, every document" |
all_documents |
The last one is not decoration. The scope rules exist to stop a sweep nobody asked for,
not to make a sweep hard to ask for, and that is the check that keeps the difference
honest.
npm run scenarios # each scenario once
npm run scenarios -- --runs 5 # five trials each, for a pass rate
npm run scenarios -- --only no-context --keep # keep the workspace and transcript
It uses your own Claude Code login and costs whatever a handful of small sessions costs,
which is why it is a script rather than part of npm test, and why CI never runs it. A
model is not a deterministic function: read the pass rates as evidence, and a failure as
a reason to open the transcript, not as a broken build.
Releasing
Anything that reaches main and can change what ships is released as a patch,
automatically — including an auto-merged dependency update. Changes that cannot affect
the packaged extension (documentation, workflows, editor config) are excluded, so they
do not spend a version number.
For a minor or major, push the tag yourself:
git tag v0.2.0 && git push origin v0.2.0
Subsequent automatic patches continue from whatever the newest tag is, so a hand-pushed
v0.2.0 makes the next automatic release v0.2.1.
Either path runs the checks, packages the extension, tags the commit, creates a GitHub
Release with the .vsix attached, and publishes to the VS Code Marketplace and Open VSX.
The released version comes from the tag history rather than from the committed
package.json, which is set to match at build time. Nobody is present to edit a version
field during an automatic release, and a bot committing one back to main would have to
be exempted from branch protection to do it. The committed version only sets the floor
for the first release.
Publishing needs two repository secrets, VSCE_PAT and OVSX_PAT. Either one being
absent skips its registry and leaves the rest of the release intact.
VSCE_PAT is an Azure DevOps personal access token with the Marketplace → Manage
permission, scoped to your own organization — Azure DevOps is retiring globally scoped
tokens from 1 December 2026. It expires after a year at most. npx vsce verify-pat magicmicky checks one in seconds, which is worth doing before trusting it to a
release. When it lapses, the release workflow fails with the rotation steps printed
in the run summary — the GitHub Release and its .vsix are created first, so an expired
token delays the registry update without blocking the release itself. Rotate the secret
and use Re-run failed jobs; the release step is idempotent and no version bump is
needed.