markdown-review v0.0.1 spike: comment-on-markdown -> agent (MCP Apps)
Antigravity-style inline markdown commenting as an installable Claude plugin. - MCP Apps webview (markdown-it renderer + comment UI) - three lanes: Answer Now (sendMessage), Add to batch, Submit All (persist+send) - edit/review mode toggle; sidecar comment persistence - self-contained prebuilt server/dist (zero runtime node_modules) - self-marketplace manifest for /plugin install - 11/11 stdio smoke tests passing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
8eba380a3f
22 changed files with 62041 additions and 0 deletions
15
.claude-plugin/marketplace.json
Normal file
15
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "tlc-plugins",
|
||||
"owner": { "name": "TLC AI Lab" },
|
||||
"metadata": {
|
||||
"description": "TLC AI Lab Claude plugins",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "markdown-review",
|
||||
"source": "./",
|
||||
"description": "Antigravity-style inline commenting on markdown — highlight a span, leave a comment, send it to the agent. Batch Submit-All, ephemeral Answer-Now, edit/review modes, in a live MCP-Apps webview."
|
||||
}
|
||||
]
|
||||
}
|
||||
7
.claude-plugin/plugin.json
Normal file
7
.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "markdown-review",
|
||||
"version": "0.0.1",
|
||||
"description": "Antigravity-style inline commenting on markdown — highlight a span, leave a comment, send it to the agent. Batch 'Submit All', ephemeral 'Answer Now', and edit/review modes, rendered in a live MCP-Apps webview.",
|
||||
"author": { "name": "TLC AI Lab (JARVIS)" },
|
||||
"license": "MIT"
|
||||
}
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Reference clone of modelcontextprotocol/ext-apps — dev aid, not part of the plugin
|
||||
_ref-ext-apps/
|
||||
|
||||
# Dependencies (build-time only; runtime is the self-contained server/dist/)
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# NOTE: server/dist/ IS committed — it is the prebuilt artifact the plugin ships.
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"md": {
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/server/dist/index.js", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
185
DESIGN.md
Normal file
185
DESIGN.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# markdown-review — Design Doc
|
||||
|
||||
> Antigravity-style **comment-on-markdown → send-to-agent** as a Claude plugin (MCP Apps / MCP-UI).
|
||||
> Status: SPEC (Phase 0 complete — capability verified, API grounded). Author: JARVIS / CDT. 2026-06-09.
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this is buildable (verification result)
|
||||
|
||||
The mechanism is **already proven on this machine**: Anthropic's `pdf-viewer` plugin is installed and
|
||||
its `pdf` MCP server is live. It renders an interactive viewer with zero Electron/browser — it ships a
|
||||
single-file HTML app as an **MCP resource** (`ui://…`, mimetype `text/html;profile=mcp-app`) that
|
||||
**Claude Desktop renders in its own sandboxed webview**. We replicate that pattern with a markdown
|
||||
renderer + comment UI.
|
||||
|
||||
**Grounded stack** (same as the working pdf server): Node 24, `@modelcontextprotocol/ext-apps@^1.7`,
|
||||
`@modelcontextprotocol/sdk@^1.29`, `zod@^4`, Vite + `vite-plugin-singlefile` to bundle `app.html`.
|
||||
|
||||
Key ext-apps primitives we use (real, verified):
|
||||
- `registerAppTool(server, name, { _meta: { ui: { resourceUri } } }, cb)` — model-facing tool that opens the view.
|
||||
- `registerAppResource(server, title, "ui://…", {}, readCb)` — serves the HTML, with CSP/permission `_meta.ui`.
|
||||
- Tool **visibility** `_meta.ui.visibility: ["app"]` — hide plumbing tools from the model (`isToolVisibilityAppOnly`).
|
||||
- App side (`@modelcontextprotocol/ext-apps/app` + `app-bridge`): the webview can **call server tools** and
|
||||
**`updateModelContext`** — push text directly into the model's turn. This is what powers "Answer Now".
|
||||
|
||||
> **Host note:** the live comment panel renders in **Claude Desktop** (the MCP-Apps host). Claude Code
|
||||
> builds/installs the plugin; the interactive surface is Desktop's. A headless Claude Code fallback
|
||||
> (render to a temp HTML + Read) is a secondary, non-interactive mode.
|
||||
|
||||
---
|
||||
|
||||
## 1. The interaction model — three lanes
|
||||
|
||||
Antigravity has two effective behaviors (batch artifact comments; select-text→chat). Rav's refinement
|
||||
adds a third, and makes the batch-result behavior a per-session toggle. The result is **three lanes**:
|
||||
|
||||
### Lane A — Reading-phase batch (the default, flow-preserving)
|
||||
You read top-to-bottom and drop comments as you go. **No AI is invoked.** Comments accumulate silently.
|
||||
A single **Submit All** flushes the whole set into *one* agent turn. This is the steering loop.
|
||||
|
||||
### Lane B — "Answer Now" (single, ephemeral, mid-read) ⟵ Rav's key addition
|
||||
A **separate** button on an individual comment. Fires **only that one item + that one comment** to the
|
||||
agent **immediately**, via `updateModelContext`. It:
|
||||
- does **not** enter the batch store,
|
||||
- does **not** disturb the other pending batch comments,
|
||||
- is for a quick correction or question *without breaking the reading phase*.
|
||||
|
||||
Mechanically distinct from Lane A: different button, different storage (memory/none), different trigger.
|
||||
|
||||
### Lane C — what Submit All *does* (per-session mode toggle)
|
||||
Orthogonal to A/B. Controls the *effect* of a batch flush:
|
||||
- **`edit` mode** — comments become edit instructions; agent modifies the anchored ranges in the real
|
||||
`.md`, re-renders, verifies. (Antigravity's steering loop.)
|
||||
- **`review` mode** — comments become a punch-list/review thread; the file is left untouched until you
|
||||
approve items one by one.
|
||||
- Switchable per session (a toolbar toggle + a tool arg). Default `edit`.
|
||||
|
||||
| Lane | Trigger | Scope | Storage | Effect |
|
||||
|------|---------|-------|---------|--------|
|
||||
| A Batch | Submit All | all pending | sidecar (default) | edit **or** review (Lane C) |
|
||||
| B Answer Now | per-comment button | that one | ephemeral (memory) | immediate single ask |
|
||||
| C (mode) | toggle | — | — | governs A's effect |
|
||||
|
||||
---
|
||||
|
||||
## 2. Anchor model
|
||||
|
||||
Because we operate on **real files** (unlike Antigravity's opaque rendered-span anchor), comments anchor
|
||||
richer and survive edits better:
|
||||
|
||||
```jsonc
|
||||
"anchor": {
|
||||
"line_start": 12, "line_end": 14, // 1-based, inclusive
|
||||
"char_start": 340, "char_end": 410, // byte/char offsets into the raw file
|
||||
"block_id": "h2-architecture-3", // stable-ish slug of the enclosing md block
|
||||
"quote": "We should use Flask here", // verbatim selected text (re-anchor fallback)
|
||||
"context_before": "…", "context_after": "…" // ~120 chars each, for fuzzy re-anchoring
|
||||
}
|
||||
```
|
||||
Re-anchoring on a changed file: try `char_start` → verify `quote` matches; else search `quote` near
|
||||
`block_id`; else fuzzy-match `quote` in `context`. Stale anchors are flagged, never silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage — "all of the above, intelligently"
|
||||
|
||||
Three backends, **selected by intent**, with explicit override. Combos are first-class.
|
||||
|
||||
| Backend | Lives in | Lifetime | Default use (scenario) |
|
||||
|---------|----------|----------|------------------------|
|
||||
| **memory** | server process, per `viewUUID` | session | **Lane B Answer-Now**; throwaway brainstorm; "don't clutter anything" |
|
||||
| **sidecar** | `.claude/md-comments/<sha1(path)>.json` | durable, resumable | **Lane A batch review threads**; multi-file reviews; keeps `.md` clean (Antigravity-like) |
|
||||
| **inline** | `<!-- mdrev:{id} … -->` markers in the `.md` | travels with file | hand-off to a colleague, **git-committed review**, portable, want comment visible in raw source |
|
||||
|
||||
### Intelligent routing (defaults; all overridable per call / toolbar)
|
||||
- Answer-Now → **memory** (never persisted).
|
||||
- Batch comment authored during reading → **sidecar** (source of truth), auto-resumes next open.
|
||||
- Explicit **"Export for sharing"** → materialize sidecar threads as **inline** markers.
|
||||
- On **open**, if the file already contains inline `mdrev:` markers → **import** them into the sidecar
|
||||
working set (combo: inline-authored → sidecar-managed).
|
||||
- **Promote**: a memory/Answer-Now comment you want to keep → "pin" → sidecar.
|
||||
|
||||
### Combos we explicitly support
|
||||
1. **sidecar SoT + inline export** — manage privately, publish a shareable copy.
|
||||
2. **inline import → sidecar** — colleague sent a `.md` with `mdrev:` comments; we ingest + manage them.
|
||||
3. **memory → sidecar promote** — a quick ask turned out worth keeping.
|
||||
4. **sidecar + inline mirror (synced)** — opt-in two-way: edits in either reflect (sidecar is authority on conflict).
|
||||
|
||||
Storage backend is a field on every comment (`store: "memory"|"sidecar"|"inline"`) so a single view can
|
||||
mix all three. `resolve`/`delete` operate uniformly across backends.
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP surface
|
||||
|
||||
### Model-facing (the only tools the model sees)
|
||||
- `open_markdown(path, { mode?: "edit"|"review" })` → `{ viewUUID, fileMeta, importedComments }`.
|
||||
Opens the view (returns `_meta.ui.resourceUri`), imports inline markers + loads sidecar.
|
||||
- `interact(viewUUID, commands[])` → batched ops the model issues:
|
||||
`scroll_to(anchor) | highlight(anchor) | get_comments(filter?) | get_state | set_mode(edit|review) |
|
||||
resolve(id) | export_inline | get_pages(rendered+source)`.
|
||||
- (`get_comments` is how the model receives a **Submit All** payload after the user clicks it.)
|
||||
|
||||
### App-only (hidden, `_meta.ui.visibility:["app"]`) — the plumbing
|
||||
- `poll_md_commands(viewUUID)` — long-poll queue draining `interact` commands to the webview.
|
||||
- `submit_batch(viewUUID, comments[], mode)` — Lane A: user clicked Submit All; resolves the model's
|
||||
pending `get_comments`/blocks the `interact` promise (pdf-viewer pattern).
|
||||
- `persist_comment(viewUUID, comment)` — webview writes a single batch comment to its backend as authored.
|
||||
- `read_md_bytes(viewUUID, range?)` — webview pulls source to render.
|
||||
- `save_md(viewUUID, bytes)` — when edit mode writes back inline markers.
|
||||
|
||||
### App→model direct (no server tool) — Lane B
|
||||
- "Answer Now" calls the bridge `updateModelContext({ item, comment })` → injected into the live turn.
|
||||
No queue, no store. (Falls back to a dedicated `answer_now` app-tool if a host lacks updateModelContext.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Round-trip (mirrors the verified pdf-viewer mechanism)
|
||||
|
||||
```
|
||||
model → interact(cmds) server enqueues to commandQueues[viewUUID]
|
||||
webview → poll_md_commands(...) long-poll; executes in the renderer (scroll/highlight/extract)
|
||||
for data cmds: interact() BLOCKS on Promise(requestId, timeout)
|
||||
user clicks "Submit All" webview → submit_batch(...) → resolves the pending model call
|
||||
user clicks "Answer Now" webview → updateModelContext(...) → straight into the turn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Plugin packaging
|
||||
|
||||
```
|
||||
markdown-review/
|
||||
├── .claude-plugin/plugin.json # name, version, author, components
|
||||
├── .mcp.json # { mcpServers: { "md": { command: node, args:[server/index.js,--stdio] } } }
|
||||
├── commands/ # /open.md /comment.md /review.md
|
||||
├── skills/review-markdown/SKILL.md # orchestration brain (open-once, batched interact, mode handling)
|
||||
├── server/ # Node MCP server (queue + blocking-promise plumbing)
|
||||
│ ├── index.js · server.js · storage.js · anchor.js
|
||||
└── app/ # Vite single-file → bundled app.html (md renderer + comment UI)
|
||||
└── src/ → built to ../server/app.html
|
||||
```
|
||||
|
||||
Renderer: `markdown-it` (CommonMark + tables/footnotes) in the sandboxed webview. Comment UI = margin
|
||||
threads anchored to selection; toolbar = mode toggle, Submit All, Export-inline, store selector;
|
||||
per-comment = Answer-Now + Submit + store-pick + resolve.
|
||||
|
||||
---
|
||||
|
||||
## 7. Build phases (slow-is-fast)
|
||||
|
||||
0. ✅ Verify host support + ground the API. *(done)*
|
||||
1. **Spike the round-trip** — minimal `md` server: open a file, render in webview, prove
|
||||
`interact`/`poll`/`submit` + `updateModelContext` fire end-to-end. De-risks everything.
|
||||
2. **Comment UI** — selection → anchored thread → Submit All payload + Answer-Now.
|
||||
3. **Storage layer** — sidecar + inline + memory + the intelligent router + combos.
|
||||
4. **Agent wiring** — SKILL.md + commands turn submitted comments into edits (edit mode) / list (review mode).
|
||||
5. **Anchor robustness** — re-anchoring across edits; stale flagging.
|
||||
6. **Package + install** — plugin.json + local marketplace entry; smoke-test in Claude Desktop.
|
||||
|
||||
## 8. Open questions / risks
|
||||
- `updateModelContext` host support in this Desktop build — confirm in Phase 1 spike; fallback = `answer_now` app-tool.
|
||||
- markdown-it bundle size in single-file (acceptable; pdf-viewer ships 4.4MB).
|
||||
- Two-way sidecar↔inline sync (combo #4) deferred past v1 — conflict policy needs care.
|
||||
```
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 TLC AI Lab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# markdown-review
|
||||
|
||||
Antigravity-style **comment-on-markdown → send-to-agent** for Claude, as a clean,
|
||||
installable plugin. Open a `.md` file in a live viewer, highlight a span, leave a
|
||||
comment, and send it to Claude — without leaving the conversation.
|
||||
|
||||
Built on [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (the same
|
||||
interactive-webview mechanism Anthropic's `pdf-viewer` plugin uses). The comment
|
||||
panel renders inside **Claude Desktop**.
|
||||
|
||||
## Three ways to comment
|
||||
|
||||
| Action | What it does |
|
||||
|--------|--------------|
|
||||
| **Answer Now** | Sends *one* comment to Claude immediately — a quick fix or question, without breaking your reading flow. Ephemeral. |
|
||||
| **Add to batch** | Hold a comment while you keep reading. Nothing is sent yet. |
|
||||
| **Submit All** | Flush the whole batch to Claude as one message, and save it to a sidecar so the review survives a reload. |
|
||||
|
||||
A per-session **Edit / Review** toggle controls what a batch *does*: in **Edit** mode
|
||||
Claude applies the comments as edits to the file; in **Review** mode they become a
|
||||
punch-list it works through with your approval.
|
||||
|
||||
Comments are stored in a sidecar (`.claude/md-comments/<hash>.json`) so your markdown
|
||||
stays clean. (Memory-only and inline-marker backends are on the roadmap — see `DESIGN.md`.)
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.scannersend.org/tlc/markdown-review
|
||||
/plugin install markdown-review@tlc-plugins
|
||||
```
|
||||
|
||||
(or `/plugin marketplace add <local-path>` to install from a clone.)
|
||||
|
||||
The plugin ships a prebuilt, self-contained server (`server/dist/`), so there is no
|
||||
`npm install` step on a fresh machine — it runs on `node` alone.
|
||||
|
||||
## Use
|
||||
|
||||
```
|
||||
/review path/to/notes.md # opens in edit mode
|
||||
/review path/to/spec.md review # opens in review (punch-list) mode
|
||||
```
|
||||
|
||||
…or just ask: *"open README.md so I can comment on it."*
|
||||
|
||||
## Develop
|
||||
|
||||
Source lives in `server/src/` (TypeScript). Rebuild the shipped artifacts:
|
||||
|
||||
```
|
||||
cd server
|
||||
npm install
|
||||
npm run build # -> dist/index.js (server) + dist/mcp-app.html (webview)
|
||||
npm run typecheck
|
||||
node test/smoke.mjs # end-to-end server contract test
|
||||
```
|
||||
|
||||
- `src/server.ts` — MCP tools (`open_markdown`, app-only `submit_batch`) + UI resource
|
||||
- `src/main.ts` — stdio / HTTP transports
|
||||
- `src/storage.ts` — sidecar persistence
|
||||
- `src/app/` — the webview (`index.html` + `mcp-app.ts`, markdown-it renderer + comment UI)
|
||||
- `build.mjs` — esbuild bundling to self-contained `dist/`
|
||||
|
||||
## License
|
||||
|
||||
MIT © TLC AI Lab
|
||||
13
commands/review.md
Normal file
13
commands/review.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
description: Open a markdown file in the interactive review viewer to comment on it
|
||||
argument-hint: <path-to.md> [edit|review]
|
||||
---
|
||||
|
||||
Open the markdown file `$1` in the interactive review viewer by calling the
|
||||
`open_markdown` tool (mode = `$2` if given, otherwise `edit`).
|
||||
|
||||
Then wait for the user's comments to arrive as messages. When they do:
|
||||
- In **edit** mode, apply each comment as an edit to the anchored span, then confirm.
|
||||
- In **review** mode, collect the comments into a punch-list and ask before editing.
|
||||
|
||||
Follow the `review-markdown` skill for the full contract.
|
||||
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
*.log
|
||||
# dist/ is intentionally committed: it is the self-contained, zero-runtime-deps
|
||||
# artifact the plugin ships. Rebuild with `npm run build`.
|
||||
75
server/build.mjs
Normal file
75
server/build.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Build script for the markdown-review MCP server.
|
||||
*
|
||||
* Produces two self-contained artifacts in dist/ with ZERO runtime node_modules:
|
||||
* - dist/index.js Node MCP server, all deps inlined (run: node dist/index.js --stdio)
|
||||
* - dist/mcp-app.html Single-file webview app (HTML + inlined JS/CSS)
|
||||
*
|
||||
* Build-time deps live in node_modules; runtime needs only `node`. Commit dist/
|
||||
* so a fresh install runs without `npm install`.
|
||||
*/
|
||||
import { build, context } from "esbuild";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dist = path.join(root, "dist");
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const serverOpts = {
|
||||
entryPoints: [path.join(root, "src/main.ts")],
|
||||
outfile: path.join(dist, "index.js"),
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node20",
|
||||
// ESM output that bundles CJS deps needs createRequire shimmed in.
|
||||
banner: {
|
||||
js: [
|
||||
"#!/usr/bin/env node",
|
||||
"import { createRequire as __cr } from 'node:module';",
|
||||
"const require = __cr(import.meta.url);",
|
||||
].join("\n"),
|
||||
},
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
/** Bundle the browser app to a string of JS, then inline it into the HTML shell. */
|
||||
async function buildApp() {
|
||||
const result = await build({
|
||||
entryPoints: [path.join(root, "src/app/mcp-app.ts")],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
target: "es2022",
|
||||
minify: !watch,
|
||||
sourcemap: watch ? "inline" : false,
|
||||
write: false,
|
||||
logLevel: "info",
|
||||
});
|
||||
const js = result.outputFiles[0].text;
|
||||
const shell = await readFile(path.join(root, "src/app/index.html"), "utf-8");
|
||||
const html = shell.replace("<!--APP_BUNDLE-->", `<script>${js}</script>`);
|
||||
await writeFile(path.join(dist, "mcp-app.html"), html, "utf-8");
|
||||
console.log("[build] dist/mcp-app.html");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await mkdir(dist, { recursive: true });
|
||||
if (watch) {
|
||||
const ctx = await context(serverOpts);
|
||||
await ctx.watch();
|
||||
await buildApp();
|
||||
console.log("[build] watching server; rebuild app manually on change");
|
||||
} else {
|
||||
await build(serverOpts);
|
||||
await buildApp();
|
||||
console.log("[build] done");
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
57288
server/dist/index.js
vendored
Normal file
57288
server/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
1746
server/dist/mcp-app.html
vendored
Normal file
1746
server/dist/mcp-app.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1832
server/package-lock.json
generated
Normal file
1832
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
server/package.json
Normal file
26
server/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@tlc/markdown-review-server",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP Apps server backing the markdown-review plugin (comment-on-markdown -> agent).",
|
||||
"license": "MIT",
|
||||
"bin": { "mcp-md-server": "dist/index.js" },
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
104
server/src/app/index.html
Normal file
104
server/src/app/index.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markdown Review</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--panel: #12151c;
|
||||
--ink: #e6e9ef;
|
||||
--muted: #8b94a7;
|
||||
--accent: #39d0ff;
|
||||
--accent-dim: #1d6f86;
|
||||
--mark: rgba(57, 208, 255, 0.22);
|
||||
--border: #232838;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.6 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar .title { font-weight: 600; letter-spacing: .2px; }
|
||||
.topbar .path { color: var(--muted); font-size: 12px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; max-width: 38vw; }
|
||||
.spacer { flex: 1; }
|
||||
button {
|
||||
font: inherit; color: var(--ink); background: #1a1f2b;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
padding: 6px 11px; cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent-dim); }
|
||||
button.primary { background: var(--accent-dim); border-color: var(--accent); }
|
||||
button.primary:disabled { opacity: .45; cursor: default; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||
.seg button { border: 0; border-radius: 0; padding: 6px 10px; background: transparent; }
|
||||
.seg button.on { background: var(--accent-dim); }
|
||||
.wrap { display: flex; height: calc(100% - 49px); }
|
||||
.doc { flex: 1; overflow: auto; padding: 28px 36px; max-width: 820px; }
|
||||
.doc h1,.doc h2,.doc h3 { line-height: 1.25; }
|
||||
.doc pre { background: #0f1219; padding: 12px 14px; border-radius: 8px; overflow: auto; }
|
||||
.doc code { background: #0f1219; padding: 1px 5px; border-radius: 4px; }
|
||||
.doc blockquote { border-left: 3px solid var(--accent-dim); margin: 0; padding-left: 14px; color: var(--muted); }
|
||||
mark.mr { background: var(--mark); border-radius: 2px; padding: 0 1px; }
|
||||
.rail { width: 270px; border-left: 1px solid var(--border); overflow: auto;
|
||||
padding: 14px; background: #0d1016; }
|
||||
.rail h4 { margin: 4px 0 10px; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .6px; }
|
||||
.ccard { background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 10px; margin-bottom: 9px; }
|
||||
.ccard .q { color: var(--muted); font-size: 12px; border-left: 2px solid var(--accent-dim);
|
||||
padding-left: 7px; margin-bottom: 5px; }
|
||||
.ccard .b { white-space: pre-wrap; }
|
||||
.empty { color: var(--muted); font-size: 13px; }
|
||||
/* selection popover */
|
||||
.pop { position: absolute; z-index: 20; width: 300px; background: var(--panel);
|
||||
border: 1px solid var(--accent-dim); border-radius: 10px; padding: 10px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.5); display: none; }
|
||||
.pop .q { color: var(--muted); font-size: 12px; max-height: 54px; overflow: auto;
|
||||
border-left: 2px solid var(--accent-dim); padding-left: 7px; margin-bottom: 8px; }
|
||||
.pop textarea { width: 100%; min-height: 56px; resize: vertical; background: #0f1219;
|
||||
color: var(--ink); border: 1px solid var(--border); border-radius: 7px; padding: 7px; font: inherit; }
|
||||
.pop .row { display: flex; gap: 7px; margin-top: 8px; }
|
||||
.pop .row button { flex: 1; }
|
||||
.hint { color: var(--muted); font-size: 12px; padding: 0 16px 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<span class="title">Markdown Review</span>
|
||||
<span class="path" id="path">no file</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="seg" id="mode" title="What 'Submit All' does">
|
||||
<button data-mode="edit" class="on">Edit</button>
|
||||
<button data-mode="review">Review</button>
|
||||
</span>
|
||||
<button class="primary" id="submitAll" disabled>Submit All (0)</button>
|
||||
</div>
|
||||
<div class="hint" id="hint">Select text in the document to leave a comment.</div>
|
||||
<div class="wrap">
|
||||
<div class="doc" id="doc"></div>
|
||||
<div class="rail">
|
||||
<h4>Comments</h4>
|
||||
<div id="rail"><div class="empty">No comments yet.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pop" id="pop">
|
||||
<div class="q" id="popQuote"></div>
|
||||
<textarea id="popText" placeholder="Your comment or question…"></textarea>
|
||||
<div class="row">
|
||||
<button id="answerNow" class="primary" title="Send just this one to the agent now">Answer Now</button>
|
||||
<button id="addBatch" title="Hold for 'Submit All'">Add to batch</button>
|
||||
</div>
|
||||
</div>
|
||||
<!--APP_BUNDLE-->
|
||||
</body>
|
||||
</html>
|
||||
212
server/src/app/mcp-app.ts
Normal file
212
server/src/app/mcp-app.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* markdown-review webview app.
|
||||
*
|
||||
* Renders a markdown file and lets the user comment on selected spans. Three lanes:
|
||||
* - Answer Now → app.sendMessage(one comment) immediately (ephemeral, Lane B)
|
||||
* - Add to batch → held locally (Lane A)
|
||||
* - Submit All → callServerTool('submit_batch') to persist, then sendMessage the bundle
|
||||
*
|
||||
* SPIKE anchoring: offsets are derived by locating the selected text in the raw
|
||||
* markdown (indexOf). Robust re-anchoring across edits is Phase 5 (DESIGN.md §2).
|
||||
*/
|
||||
import { App } from "@modelcontextprotocol/ext-apps";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
interface Anchor {
|
||||
line_start: number;
|
||||
line_end: number;
|
||||
char_start: number;
|
||||
char_end: number;
|
||||
quote: string;
|
||||
}
|
||||
interface Comment {
|
||||
id: string;
|
||||
anchor: Anchor;
|
||||
body: string;
|
||||
store: "memory" | "sidecar" | "inline";
|
||||
created: string;
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: false });
|
||||
|
||||
const state = {
|
||||
path: "" as string,
|
||||
markdown: "" as string,
|
||||
mode: "edit" as "edit" | "review",
|
||||
batch: [] as Comment[],
|
||||
pending: null as Anchor | null,
|
||||
};
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const docEl = $("doc");
|
||||
const railEl = $("rail");
|
||||
const pathEl = $("path");
|
||||
const popEl = $("pop");
|
||||
const popQuote = $("popQuote");
|
||||
const popText = $<HTMLTextAreaElement>("popText");
|
||||
const submitAllBtn = $<HTMLButtonElement>("submitAll");
|
||||
|
||||
const app = new App({ name: "Markdown Review", version: "0.0.1" });
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
function renderDoc(): void {
|
||||
docEl.innerHTML = md.render(state.markdown);
|
||||
pathEl.textContent = state.path || "no file";
|
||||
}
|
||||
|
||||
function renderRail(): void {
|
||||
if (state.batch.length === 0) {
|
||||
railEl.innerHTML = `<div class="empty">No comments yet.</div>`;
|
||||
} else {
|
||||
railEl.innerHTML = state.batch
|
||||
.map(
|
||||
(c) =>
|
||||
`<div class="ccard"><div class="q">${escapeHtml(
|
||||
truncate(c.anchor.quote, 90),
|
||||
)}</div><div class="b">${escapeHtml(c.body)}</div></div>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
submitAllBtn.textContent = `Submit All (${state.batch.length})`;
|
||||
submitAllBtn.disabled = state.batch.length === 0;
|
||||
}
|
||||
|
||||
// ── selection → anchor ──────────────────────────────────────────────────────────
|
||||
function anchorFromSelection(text: string): Anchor {
|
||||
const idx = state.markdown.indexOf(text);
|
||||
const char_start = idx >= 0 ? idx : 0;
|
||||
const char_end = idx >= 0 ? idx + text.length : text.length;
|
||||
const before = state.markdown.slice(0, char_start);
|
||||
const line_start = before.split("\n").length;
|
||||
const line_end = line_start + text.split("\n").length - 1;
|
||||
return { line_start, line_end, char_start, char_end, quote: text };
|
||||
}
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim() ?? "";
|
||||
if (!text || !sel || sel.rangeCount === 0 || !docEl.contains(sel.anchorNode)) {
|
||||
return;
|
||||
}
|
||||
state.pending = anchorFromSelection(text);
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
popQuote.textContent = truncate(text, 160);
|
||||
popText.value = "";
|
||||
popEl.style.display = "block";
|
||||
const top = Math.min(rect.bottom + window.scrollY + 6, window.innerHeight - 180);
|
||||
popEl.style.top = `${top}px`;
|
||||
popEl.style.left = `${Math.min(rect.left + window.scrollX, window.innerWidth - 320)}px`;
|
||||
popText.focus();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (!popEl.contains(e.target as Node)) popEl.style.display = "none";
|
||||
});
|
||||
|
||||
// ── lane B: Answer Now ──────────────────────────────────────────────────────────
|
||||
$("answerNow").addEventListener("click", async () => {
|
||||
const body = popText.value.trim();
|
||||
if (!state.pending || !body) return;
|
||||
popEl.style.display = "none";
|
||||
await sendToAgent([{ ...mkComment(state.pending, body, "memory") }], /*single*/ true);
|
||||
state.pending = null;
|
||||
});
|
||||
|
||||
// ── lane A: add to batch ────────────────────────────────────────────────────────
|
||||
$("addBatch").addEventListener("click", () => {
|
||||
const body = popText.value.trim();
|
||||
if (!state.pending || !body) return;
|
||||
state.batch.push(mkComment(state.pending, body, "sidecar"));
|
||||
state.pending = null;
|
||||
popEl.style.display = "none";
|
||||
renderRail();
|
||||
});
|
||||
|
||||
// ── lane A flush: Submit All ────────────────────────────────────────────────────
|
||||
submitAllBtn.addEventListener("click", async () => {
|
||||
if (state.batch.length === 0) return;
|
||||
try {
|
||||
await app.callServerTool({
|
||||
name: "submit_batch",
|
||||
arguments: { file: state.path, comments: state.batch, mode: state.mode },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("submit_batch persist failed:", e);
|
||||
}
|
||||
await sendToAgent(state.batch, /*single*/ false);
|
||||
state.batch = [];
|
||||
renderRail();
|
||||
});
|
||||
|
||||
// ── mode toggle ─────────────────────────────────────────────────────────────────
|
||||
$("mode").addEventListener("click", (e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
const m = t.dataset.mode as "edit" | "review" | undefined;
|
||||
if (!m) return;
|
||||
state.mode = m;
|
||||
$("mode")
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => b.classList.toggle("on", (b as HTMLElement).dataset.mode === m));
|
||||
});
|
||||
|
||||
// ── send to the conversation ────────────────────────────────────────────────────
|
||||
async function sendToAgent(comments: Comment[], single: boolean): Promise<void> {
|
||||
const verb =
|
||||
state.mode === "edit"
|
||||
? "Apply these review comments by editing the file"
|
||||
: "Add these review comments to a punch-list (do not edit the file yet)";
|
||||
const header = single
|
||||
? `Quick question on \`${state.path}\`:`
|
||||
: `${verb} \`${state.path}\` (${comments.length} comment(s)):`;
|
||||
const blocks = comments
|
||||
.map(
|
||||
(c) =>
|
||||
`--- L${c.anchor.line_start}-${c.anchor.line_end}\n> ${c.anchor.quote.replace(
|
||||
/\n/g,
|
||||
"\n> ",
|
||||
)}\nComment: ${c.body}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
const text = `${header}\n\n${blocks}`;
|
||||
try {
|
||||
const { isError } = await app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
if (isError) console.warn("Host rejected sendMessage");
|
||||
} catch (e) {
|
||||
console.error("sendMessage failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── host wiring ─────────────────────────────────────────────────────────────────
|
||||
function ingestResult(result: CallToolResult): void {
|
||||
const sc = result.structuredContent as
|
||||
| { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] }
|
||||
| undefined;
|
||||
if (!sc?.markdown) return;
|
||||
state.path = sc.path ?? "";
|
||||
state.markdown = sc.markdown;
|
||||
state.mode = (sc.mode as "edit" | "review") ?? "edit";
|
||||
renderDoc();
|
||||
renderRail();
|
||||
}
|
||||
|
||||
app.ontoolresult = ingestResult;
|
||||
app.onerror = console.error;
|
||||
app.connect();
|
||||
|
||||
// ── utils ───────────────────────────────────────────────────────────────────────
|
||||
let seq = 0;
|
||||
function mkComment(anchor: Anchor, body: string, store: Comment["store"]): Comment {
|
||||
return { id: `c${Date.now().toString(36)}-${seq++}`, anchor, body, store, created: new Date().toISOString() };
|
||||
}
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||
}
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string),
|
||||
);
|
||||
}
|
||||
62
server/src/main.ts
Normal file
62
server/src/main.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Entry point for the markdown-review MCP server.
|
||||
*
|
||||
* node dist/index.js --stdio stdio transport (how the plugin launches it)
|
||||
* node dist/index.js Streamable HTTP on $PORT (default 3017) for local dev
|
||||
*/
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
async function startStdio(): Promise<void> {
|
||||
await createServer().connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
async function startHttp(): Promise<void> {
|
||||
const { createMcpExpressApp } = await import(
|
||||
"@modelcontextprotocol/sdk/server/express.js"
|
||||
);
|
||||
const { StreamableHTTPServerTransport } = await import(
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp.js"
|
||||
);
|
||||
const port = Number.parseInt(process.env.PORT ?? "3017", 10);
|
||||
const app = createMcpExpressApp({ host: "127.0.0.1" });
|
||||
app.all("/mcp", async (req: any, res: any) => {
|
||||
const server = createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close().catch(() => {});
|
||||
server.close().catch(() => {});
|
||||
});
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
console.error("MCP error:", err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
app.listen(port, () =>
|
||||
console.error(`markdown-review MCP server on http://127.0.0.1:${port}/mcp`),
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.argv.includes("--stdio")) {
|
||||
await startStdio();
|
||||
} else {
|
||||
await startHttp();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
148
server/src/server.ts
Normal file
148
server/src/server.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* markdown-review MCP server.
|
||||
*
|
||||
* Model-facing surface (what Claude sees):
|
||||
* - open_markdown(path, mode?) open a .md in the live comment viewer
|
||||
*
|
||||
* App-only surface (hidden from the model, called by the webview via the host):
|
||||
* - submit_batch(file, comments, mode) persist a reading-phase batch to the sidecar
|
||||
*
|
||||
* The "send to agent" action itself is performed by the webview through the host
|
||||
* (app.sendMessage) — see src/app/mcp-app.ts. This server owns file IO + storage.
|
||||
*/
|
||||
import {
|
||||
registerAppResource,
|
||||
registerAppTool,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
import { loadSidecar, saveSidecar, type Comment } from "./storage.js";
|
||||
|
||||
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RESOURCE_URI = "ui://markdown-review/mcp-app.html";
|
||||
|
||||
const AnchorSchema = z.object({
|
||||
line_start: z.number(),
|
||||
line_end: z.number(),
|
||||
char_start: z.number(),
|
||||
char_end: z.number(),
|
||||
quote: z.string(),
|
||||
});
|
||||
|
||||
const CommentSchema = z.object({
|
||||
id: z.string(),
|
||||
anchor: AnchorSchema,
|
||||
body: z.string(),
|
||||
store: z.enum(["memory", "sidecar", "inline"]).default("sidecar"),
|
||||
created: z.string(),
|
||||
resolved: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "markdown-review",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// ── open_markdown ──────────────────────────────────────────────────────────
|
||||
registerAppTool(
|
||||
server,
|
||||
"open_markdown",
|
||||
{
|
||||
title: "Open markdown for review",
|
||||
description:
|
||||
"Open a markdown file in the interactive review viewer. The user can " +
|
||||
"highlight spans and leave comments; 'Answer Now' sends a single comment " +
|
||||
"to you immediately, 'Submit All' sends the batch. Returns the file's " +
|
||||
"content and any previously saved comments.",
|
||||
inputSchema: {
|
||||
path: z.string().describe("Absolute or workspace-relative path to a .md file"),
|
||||
mode: z
|
||||
.enum(["edit", "review"])
|
||||
.default("edit")
|
||||
.describe("edit = comments become edits to the file; review = punch-list only"),
|
||||
},
|
||||
outputSchema: {
|
||||
path: z.string(),
|
||||
mode: z.string(),
|
||||
markdown: z.string(),
|
||||
existingComments: z.array(z.any()),
|
||||
},
|
||||
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
||||
},
|
||||
async ({ path: filePath, mode }): Promise<CallToolResult> => {
|
||||
const abs = path.resolve(process.cwd(), filePath);
|
||||
const markdown = await readFile(abs, "utf-8");
|
||||
const sidecar = await loadSidecar(abs);
|
||||
const structured = {
|
||||
path: abs,
|
||||
mode,
|
||||
markdown,
|
||||
existingComments: sidecar.comments,
|
||||
};
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Opened ${abs} in the review viewer (${mode} mode), ` +
|
||||
`${sidecar.comments.length} existing comment(s).`,
|
||||
},
|
||||
],
|
||||
structuredContent: structured,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── submit_batch (app-only) ─────────────────────────────────────────────────
|
||||
registerAppTool(
|
||||
server,
|
||||
"submit_batch",
|
||||
{
|
||||
title: "Persist a comment batch",
|
||||
description:
|
||||
"Internal: the review viewer calls this to persist a reading-phase batch " +
|
||||
"to the sidecar store. The model should not call this directly.",
|
||||
inputSchema: {
|
||||
file: z.string(),
|
||||
comments: z.array(CommentSchema),
|
||||
mode: z.enum(["edit", "review"]).default("edit"),
|
||||
},
|
||||
outputSchema: {
|
||||
saved: z.boolean(),
|
||||
sidecar: z.string(),
|
||||
count: z.number(),
|
||||
},
|
||||
_meta: { ui: { visibility: ["app"] } },
|
||||
},
|
||||
async ({ file, comments }): Promise<CallToolResult> => {
|
||||
const sidecarPath = await saveSidecar(file, comments as Comment[]);
|
||||
const structured = { saved: true, sidecar: sidecarPath, count: comments.length };
|
||||
return {
|
||||
content: [{ type: "text", text: `Saved ${comments.length} comment(s).` }],
|
||||
structuredContent: structured,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── UI resource ─────────────────────────────────────────────────────────────
|
||||
registerAppResource(
|
||||
server,
|
||||
RESOURCE_URI,
|
||||
RESOURCE_URI,
|
||||
{ mimeType: RESOURCE_MIME_TYPE },
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
|
||||
return {
|
||||
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
65
server/src/storage.ts
Normal file
65
server/src/storage.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Comment persistence backends.
|
||||
*
|
||||
* SPIKE SCOPE: the `sidecar` backend only — comments stored as JSON next to a
|
||||
* `.claude/md-comments/` directory, keyed by a hash of the absolute file path,
|
||||
* so the source markdown stays clean. The `memory` and `inline` backends (and
|
||||
* the intelligent router) come in Phase 3 — see DESIGN.md §3.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface CommentAnchor {
|
||||
line_start: number;
|
||||
line_end: number;
|
||||
char_start: number;
|
||||
char_end: number;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
anchor: CommentAnchor;
|
||||
body: string;
|
||||
store: "memory" | "sidecar" | "inline";
|
||||
created: string;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export interface CommentDoc {
|
||||
version: 1;
|
||||
file: string;
|
||||
updated: string;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
/** Sidecar lives under the nearest workspace's .claude dir, by path hash. */
|
||||
function sidecarPathFor(absFile: string): string {
|
||||
const hash = createHash("sha1").update(absFile).digest("hex").slice(0, 16);
|
||||
const dir = path.join(path.dirname(absFile), ".claude", "md-comments");
|
||||
return path.join(dir, `${hash}.json`);
|
||||
}
|
||||
|
||||
export async function loadSidecar(absFile: string): Promise<CommentDoc> {
|
||||
const p = sidecarPathFor(absFile);
|
||||
try {
|
||||
const raw = await readFile(p, "utf-8");
|
||||
return JSON.parse(raw) as CommentDoc;
|
||||
} catch {
|
||||
return { version: 1, file: absFile, updated: new Date().toISOString(), comments: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSidecar(absFile: string, comments: Comment[]): Promise<string> {
|
||||
const p = sidecarPathFor(absFile);
|
||||
await mkdir(path.dirname(p), { recursive: true });
|
||||
const doc: CommentDoc = {
|
||||
version: 1,
|
||||
file: absFile,
|
||||
updated: new Date().toISOString(),
|
||||
comments,
|
||||
};
|
||||
await writeFile(p, JSON.stringify(doc, null, 2), "utf-8");
|
||||
return p;
|
||||
}
|
||||
88
server/test/smoke.mjs
Normal file
88
server/test/smoke.mjs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* End-to-end smoke test: launch the built server over stdio with a real MCP
|
||||
* client, list tools, read the UI resource, and exercise open_markdown +
|
||||
* submit_batch. No host/webview needed — this verifies the server contract.
|
||||
*/
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { writeFile, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(here, "..");
|
||||
const sample = path.join(here, "sample.md");
|
||||
let failures = 0;
|
||||
const ok = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) failures++; };
|
||||
|
||||
await writeFile(sample, "# Title\n\nFirst paragraph with **bold**.\n\n- item one\n- item two\n");
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [path.join(root, "dist", "index.js"), "--stdio"],
|
||||
});
|
||||
const client = new Client({ name: "smoke", version: "0.0.0" });
|
||||
await client.connect(transport);
|
||||
|
||||
// 1. tools/list — model-facing visibility
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((t) => t.name).sort();
|
||||
ok(names.includes("open_markdown"), "open_markdown tool registered");
|
||||
ok(names.includes("submit_batch"), "submit_batch tool registered");
|
||||
const openTool = tools.find((t) => t.name === "open_markdown");
|
||||
ok(
|
||||
JSON.stringify(openTool?._meta ?? {}).includes("ui://markdown-review/mcp-app.html"),
|
||||
"open_markdown carries ui.resourceUri meta",
|
||||
);
|
||||
|
||||
// 2. resources — the UI html is served
|
||||
const { resources } = await client.listResources();
|
||||
ok(
|
||||
resources.some((r) => r.uri === "ui://markdown-review/mcp-app.html"),
|
||||
"UI resource listed",
|
||||
);
|
||||
const res = await client.readResource({ uri: "ui://markdown-review/mcp-app.html" });
|
||||
ok(
|
||||
(res.contents?.[0]?.text ?? "").includes("Markdown Review"),
|
||||
"UI resource returns the app HTML",
|
||||
);
|
||||
|
||||
// 3. open_markdown returns the file content + empty comment set
|
||||
const opened = await client.callTool({
|
||||
name: "open_markdown",
|
||||
arguments: { path: sample, mode: "edit" },
|
||||
});
|
||||
const sc = opened.structuredContent ?? {};
|
||||
ok(sc.markdown?.includes("First paragraph"), "open_markdown returns file content");
|
||||
ok(Array.isArray(sc.existingComments) && sc.existingComments.length === 0, "no pre-existing comments");
|
||||
ok(sc.mode === "edit", "mode echoed back");
|
||||
|
||||
// 4. submit_batch persists to the sidecar, then open_markdown reads it back
|
||||
const comment = {
|
||||
id: "c-test-1",
|
||||
anchor: { line_start: 3, line_end: 3, char_start: 9, char_end: 24, quote: "First paragraph" },
|
||||
body: "Tighten this sentence.",
|
||||
store: "sidecar",
|
||||
created: new Date().toISOString(),
|
||||
};
|
||||
const saved = await client.callTool({
|
||||
name: "submit_batch",
|
||||
arguments: { file: sc.path, comments: [comment], mode: "edit" },
|
||||
});
|
||||
ok(saved.structuredContent?.saved === true, "submit_batch reports saved");
|
||||
ok(saved.structuredContent?.count === 1, "submit_batch count == 1");
|
||||
|
||||
const reopened = await client.callTool({
|
||||
name: "open_markdown",
|
||||
arguments: { path: sample, mode: "review" },
|
||||
});
|
||||
const round = reopened.structuredContent?.existingComments ?? [];
|
||||
ok(round.length === 1 && round[0].body === "Tighten this sentence.", "sidecar comment round-trips on reopen");
|
||||
|
||||
await client.close();
|
||||
// cleanup
|
||||
await rm(sample, { force: true });
|
||||
await rm(path.join(here, ".claude"), { recursive: true, force: true });
|
||||
|
||||
console.log(`\n${failures === 0 ? "ALL PASSED" : failures + " FAILED"}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "build.mjs"]
|
||||
}
|
||||
47
skills/review-markdown/SKILL.md
Normal file
47
skills/review-markdown/SKILL.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
name: review-markdown
|
||||
description: Open a markdown file in an interactive review viewer where the user highlights spans and leaves comments, then sends them to you. Use when the user wants to comment on, review, mark up, or give feedback on a .md file — or says "let me comment on this", "open this for review", "review this markdown".
|
||||
---
|
||||
|
||||
# review-markdown
|
||||
|
||||
Open a markdown file in the live MCP-Apps review viewer so the user can highlight
|
||||
text and leave comments, Google-Docs style. Comments come back to you as ordinary
|
||||
messages — you act on them.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Call the **`open_markdown`** tool with the file path and a `mode`:
|
||||
- `mode: "edit"` (default) — submitted comments are edit instructions; you modify
|
||||
the anchored ranges in the file, then re-render/verify.
|
||||
- `mode: "review"` — submitted comments are a punch-list; do **not** edit the file
|
||||
until the user approves items.
|
||||
2. The viewer opens in Claude Desktop. The user has three actions:
|
||||
- **Answer Now** — sends a single comment to you *immediately* (a quick question or
|
||||
fix). Handle it on its own without waiting for anything else.
|
||||
- **Add to batch** — the user is accumulating comments while reading; nothing reaches
|
||||
you yet.
|
||||
- **Submit All** — flushes the whole batch to you as one message and persists it to a
|
||||
sidecar (`.claude/md-comments/<hash>.json`).
|
||||
3. Comments arrive as messages shaped like:
|
||||
```
|
||||
Apply these review comments by editing `path.md` (N comment(s)):
|
||||
|
||||
--- L12-14
|
||||
> the quoted span
|
||||
Comment: the user's note
|
||||
```
|
||||
Respect the verb in the header (edit vs punch-list).
|
||||
|
||||
## Rules
|
||||
|
||||
- Call `open_markdown` **once** per file; the viewer stays live.
|
||||
- Honor the **mode** stated in each incoming message header — don't edit the file in
|
||||
review mode.
|
||||
- For an **Answer Now** message ("Quick question on …"), respond to just that one item;
|
||||
don't assume there are others.
|
||||
- When editing from a comment, anchor on the **quoted span** (and the `Lx-y` lines) to
|
||||
locate the exact text; read the file region before editing (it may have shifted).
|
||||
- Never call `submit_batch` yourself — it is the viewer's internal persistence tool.
|
||||
- Re-opening a file surfaces previously saved sidecar comments in `existingComments`;
|
||||
use them to resume an unfinished review.
|
||||
Loading…
Add table
Reference in a new issue