mdinterface 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/access.js +60 -0
- package/mcp-server.js +269 -0
- package/package.json +64 -0
- package/public/index.html +805 -0
- package/public/render-core.js +103 -0
- package/public/vendor/addon-fit.min.js +8 -0
- package/public/vendor/addon-webgl.min.js +8 -0
- package/public/vendor/marked.min.js +6 -0
- package/public/vendor/purify.min.js +3 -0
- package/public/vendor/xterm.min.css +8 -0
- package/public/vendor/xterm.min.js +8 -0
- package/server.js +723 -0
- package/start.sh +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Sundstrom
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# mdinterface
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kevinsundstrom/mdinterface/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
<!-- TODO: drop a demo GIF here (select text → ask → the block flashes and re-renders). It's a
|
|
8
|
+
visual tool; a 10-second capture sells it faster than any paragraph. e.g. docs/demo.gif -->
|
|
9
|
+
|
|
10
|
+
mdinterface makes editing with Claude precise. A rendered markdown canvas sits beside a live
|
|
11
|
+
Claude session, bridged by the file on disk. The passage you highlight sets both what Claude
|
|
12
|
+
sees and what it is allowed to change, so edits stay scoped to what you pointed at instead of
|
|
13
|
+
sprawling across the document. Ask for a small fix or a full rewrite of that part, and watch
|
|
14
|
+
just that block change and re-render.
|
|
15
|
+
|
|
16
|
+
The two panes never talk to each other directly. The file on disk is the interface,
|
|
17
|
+
and Claude's awareness is wired through its hooks, not by typing into its prompt:
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
full doc ──SessionStart hook──▶ in Claude's context from the start
|
|
21
|
+
canvas selection ──file + UserPromptSubmit hook──▶ rides along with each message
|
|
22
|
+
Claude Code ──edits──────────────▶ the file on disk
|
|
23
|
+
file watcher ──content────────────▶ canvas re-render
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
cd mdinterface
|
|
28
|
+
npm install # express, ws, node-pty (ships a native helper)
|
|
29
|
+
|
|
30
|
+
Requires Node 18+ and the `claude` CLI on your PATH.
|
|
31
|
+
|
|
32
|
+
If the terminal pane says **"Terminal unavailable,"** `node-pty`'s prebuilt helper lost
|
|
33
|
+
its executable bit (a common install hiccup). Restore it:
|
|
34
|
+
|
|
35
|
+
chmod +x node_modules/node-pty/prebuilds/*/spawn-helper
|
|
36
|
+
|
|
37
|
+
If there's no prebuilt binary for your platform, build it instead (needs Xcode Command
|
|
38
|
+
Line Tools / build-essential):
|
|
39
|
+
|
|
40
|
+
npm rebuild node-pty
|
|
41
|
+
|
|
42
|
+
The rendering and terminal libraries (`marked`, `xterm` and its addons, `DOMPurify`) are
|
|
43
|
+
**bundled in `public/vendor/`** and served locally, so mdinterface works fully offline — no
|
|
44
|
+
CDN, no first-load internet requirement.
|
|
45
|
+
|
|
46
|
+
## Run
|
|
47
|
+
|
|
48
|
+
node server.js path/to/doc.md
|
|
49
|
+
# prints a URL like http://localhost:7777/?t=… — open THAT (it carries a session token)
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
|
|
53
|
+
--port 8000 # different port
|
|
54
|
+
--cmd "claude --continue" # custom launch command (or set MDINTERFACE_CMD)
|
|
55
|
+
|
|
56
|
+
## Use
|
|
57
|
+
|
|
58
|
+
- **Select** any text in the rendered doc and it becomes *ambient context* — nothing is
|
|
59
|
+
typed into the prompt. The selection is mirrored to `.claude/mdinterface-selection.txt`
|
|
60
|
+
next to the doc, and a `UserPromptSubmit` hook (auto-installed into
|
|
61
|
+
`.claude/settings.local.json`) silently attaches it to your next message. Give a normal
|
|
62
|
+
instruction — "tighten this up", "explain this" — and Claude already knows what "this"
|
|
63
|
+
is. Clear the selection and it stops.
|
|
64
|
+
- **Edits apply immediately** — Claude changes the file and the canvas re-renders. To take
|
|
65
|
+
one back, use the **Undo** button (rolls back the last change) or ask Claude to revert it;
|
|
66
|
+
in a git repo, every change is also one `git diff` away.
|
|
67
|
+
- The right pane is the genuine CLI: `/commands`, plan mode, `claude --resume`,
|
|
68
|
+
everything works, because it *is* claude running in a PTY.
|
|
69
|
+
- When Claude (or anything else — your editor, git checkout) changes the file,
|
|
70
|
+
only what changed flashes green and updates. Scroll position is preserved.
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- If `claude` isn't found, it falls back to your shell so you can debug.
|
|
75
|
+
- Undo = git. Run it in a repo and every accepted change is one `git diff` away.
|
|
76
|
+
|
|
77
|
+
## Security
|
|
78
|
+
|
|
79
|
+
mdinterface runs a live shell/Claude session over a local WebSocket, so it is locked down to
|
|
80
|
+
this machine only:
|
|
81
|
+
|
|
82
|
+
- The server binds to **127.0.0.1** (loopback), never reachable from the network.
|
|
83
|
+
- Every request carries a **per-launch token** (in the URL); the WebSocket also checks the
|
|
84
|
+
`Origin` and `Host` headers, so a website you visit can't connect to it.
|
|
85
|
+
- Rendered markdown HTML is **sanitized** (DOMPurify) before display.
|
|
86
|
+
|
|
87
|
+
Still your responsibility:
|
|
88
|
+
|
|
89
|
+
- **Only open documents you trust.** The whole file is fed into Claude's context and an
|
|
90
|
+
auto-approved `canvas_edit` tool can write it, so a malicious document is a
|
|
91
|
+
prompt-injection vector.
|
|
92
|
+
- **Single-user machines only.** The launch token is written to
|
|
93
|
+
`.claude/mdinterface-runtime.json` (mode `600`) so the helper process can reach the server.
|
|
94
|
+
Anyone who can read your files can read that token and drive the session — fine on your
|
|
95
|
+
own machine, not on a shared host.
|
|
96
|
+
- mdinterface **writes into the document's folder**: `.claude/settings.local.json`
|
|
97
|
+
(hooks + MCP pre-approval), `.mcp.json`, `.claude/mdinterface-selection.txt`, and
|
|
98
|
+
`.claude/mdinterface-runtime.json`. The shipped `.gitignore` covers these for mdinterface's
|
|
99
|
+
own repo; when you point it at a doc in **another** repo, add them to that repo's
|
|
100
|
+
`.gitignore`.
|
|
101
|
+
|
|
102
|
+
### Threat model — how you'd actually get owned
|
|
103
|
+
|
|
104
|
+
There is essentially **one door, and it's a document you didn't write.** A remote attacker
|
|
105
|
+
can't reach the server (loopback bind + per-launch token + WebSocket `Origin`/`Host` checks
|
|
106
|
+
mean a website you visit or someone on your network can't connect — they don't have the token
|
|
107
|
+
and can't read it cross-origin). So the only way in is to get a file *to* you and have you
|
|
108
|
+
open it in the canvas — a contributor's PR, a shared `.md`, a downloaded doc.
|
|
109
|
+
|
|
110
|
+
When you open it, the **whole file is fed into Claude's context**, so any instructions hidden
|
|
111
|
+
in it (an HTML comment, white-on-white text, or just confident prose) are read as if you'd
|
|
112
|
+
typed them. From there:
|
|
113
|
+
|
|
114
|
+
- **The dangerous escalation is `Bash`, and `Bash` is not pre-approved** — it hits the normal
|
|
115
|
+
permission prompt. So a poisoned doc degrades to social engineering: it tries to get Claude
|
|
116
|
+
to propose a benign-looking command (`npm install && ./setup.sh`) that you approve on
|
|
117
|
+
autopilot. **Approving a command you didn't ask for, with a foreign doc open, is the whole
|
|
118
|
+
ballgame** — that's RCE as you. Don't.
|
|
119
|
+
- **Never run the pane in bypass-permissions / "YOLO" mode with an untrusted doc open.** That
|
|
120
|
+
auto-approves `Bash`, turning the above into a true zero-click RCE on file open.
|
|
121
|
+
- **Zero-click, no approval needed (bounded):** an injection can still use whatever is already
|
|
122
|
+
auto-approved — e.g. a `WebFetch(domain:…)` grant becomes a silent exfiltration channel
|
|
123
|
+
(doc/selection text smuggled in a URL), and `canvas_edit` can silently tamper with the doc
|
|
124
|
+
you're about to publish. **Prune auto-approve grants you aren't using.**
|
|
125
|
+
|
|
126
|
+
What an attacker **cannot** do, by construction: reach you over the network, escape
|
|
127
|
+
`canvas_edit`/`canvas_open` into `.claude/` to plant a hook (path-scoped: realpath +
|
|
128
|
+
extension allow-list + `.claude` refusal), steal the token via XSS (sanitized, with a
|
|
129
|
+
plain-text fallback), or read the `600` token file as another user. The boundary that's
|
|
130
|
+
*yours* to hold is the first line: **treat any document you didn't write as untrusted input,
|
|
131
|
+
and don't approve commands for it.**
|
package/access.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* Access control for mdinterface. The server drives a live shell/Claude PTY, so it must be
|
|
2
|
+
* reachable only from this machine's loopback, only by clients holding the per-launch
|
|
3
|
+
* token, and (for WebSockets) only from the mdinterface page itself — not a random site you
|
|
4
|
+
* visit (WebSockets bypass same-origin) and not via DNS rebinding.
|
|
5
|
+
*
|
|
6
|
+
* Exported as a factory so it can be unit-tested without starting the server.
|
|
7
|
+
*
|
|
8
|
+
* @param {number} PORT the loopback port the server listens on
|
|
9
|
+
* @param {string} TOKEN the per-launch secret required on every request
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function makeAccess(PORT, TOKEN) {
|
|
12
|
+
const ALLOWED_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`, `[::1]:${PORT}`]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pull the `?t=` token out of a request URL (resolved against `host`).
|
|
16
|
+
* @param {string} reqUrl
|
|
17
|
+
* @param {string} [host]
|
|
18
|
+
* @returns {string | null}
|
|
19
|
+
*/
|
|
20
|
+
function tokenOf(reqUrl, host) {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(reqUrl, `http://${host || "localhost"}`).searchParams.get("t");
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True iff the WebSocket Origin is a loopback origin on our port (or absent — non-browser).
|
|
30
|
+
* Exact host equality, so `127.0.0.1.attacker.com` can't slip past a substring check.
|
|
31
|
+
* @param {string} [origin]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function originAllowed(origin) {
|
|
35
|
+
if (!origin) return true; // non-browser clients omit Origin; the token still gates them
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(origin);
|
|
38
|
+
const loopback =
|
|
39
|
+
u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
40
|
+
return loopback && (u.port === String(PORT) || (!u.port && String(PORT) === "80"));
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Gate a WebSocket upgrade: exact Host (anti-rebinding) + loopback Origin + valid token.
|
|
48
|
+
* @param {{ url?: string, headers: { host?: string, origin?: string } }} req
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function wsAllowed(req) {
|
|
52
|
+
return (
|
|
53
|
+
ALLOWED_HOSTS.has(req.headers.host) && // DNS-rebinding guard
|
|
54
|
+
originAllowed(req.headers.origin) && // cross-site WebSocket guard
|
|
55
|
+
tokenOf(req.url, req.headers.host) === TOKEN
|
|
56
|
+
); // per-launch secret token
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { ALLOWED_HOSTS, tokenOf, originAllowed, wsAllowed };
|
|
60
|
+
};
|
package/mcp-server.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* mdinterface MCP server — exposes `canvas_edit` so Claude can edit the canvas document
|
|
3
|
+
* WITHOUT the built-in Edit/Write tools' mandatory prior Read. The document is already
|
|
4
|
+
* in Claude's context (via the SessionStart hook), so it can supply old/new text
|
|
5
|
+
* directly. This process just writes the file; the running mdinterface server's file
|
|
6
|
+
* watcher broadcasts the change, so the canvas re-renders instantly.
|
|
7
|
+
*
|
|
8
|
+
* Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
|
|
9
|
+
* Only stdout carries protocol messages — never log to stdout; use stderr.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
const DOC = process.argv[2] ? path.resolve(process.argv[2]) : null;
|
|
15
|
+
const RUNTIME_FILE = DOC
|
|
16
|
+
? path.join(path.dirname(DOC), ".claude", "mdinterface-runtime.json")
|
|
17
|
+
: null;
|
|
18
|
+
|
|
19
|
+
// How to reach the mdinterface server (port/token) and which document is open (doc), written by
|
|
20
|
+
// the server. Used by canvas_open and to follow file switches; absent ⇒ empty (editing still
|
|
21
|
+
// works, since canvas_edit writes the file directly).
|
|
22
|
+
function runtime() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(RUNTIME_FILE, "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Switch the canvas to a different document via the server (so it broadcasts + re-points
|
|
31
|
+
// the watcher). The file must already exist.
|
|
32
|
+
async function canvasOpen(args) {
|
|
33
|
+
const p = args?.path;
|
|
34
|
+
if (typeof p !== "string" || !p.trim()) throw new Error("path is required.");
|
|
35
|
+
const rt = runtime();
|
|
36
|
+
if (!rt.port || !rt.token) throw new Error("The mdinterface server can't be reached.");
|
|
37
|
+
let r;
|
|
38
|
+
try {
|
|
39
|
+
r = await fetch(`http://127.0.0.1:${rt.port}/open?t=${rt.token}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ path: p }),
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("The mdinterface server can't be reached.");
|
|
46
|
+
}
|
|
47
|
+
const j = await r.json().catch(() => ({}));
|
|
48
|
+
if (j.error) throw new Error(j.error);
|
|
49
|
+
return `Opened ${path.basename(p)} in the canvas.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function send(msg) {
|
|
53
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
54
|
+
}
|
|
55
|
+
function result(id, res) {
|
|
56
|
+
send({ jsonrpc: "2.0", id, result: res });
|
|
57
|
+
}
|
|
58
|
+
function rpcError(id, code, message) {
|
|
59
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TOOLS = [
|
|
63
|
+
{
|
|
64
|
+
name: "canvas_edit",
|
|
65
|
+
description:
|
|
66
|
+
"Edit the markdown document shown in the mdinterface canvas by replacing an exact " +
|
|
67
|
+
"string. Use this INSTEAD of the built-in Edit/Write tools for the canvas document: " +
|
|
68
|
+
"it needs no prior Read (the document is already in your context) and the canvas " +
|
|
69
|
+
"updates instantly. `old_string` must match the file exactly and be unique unless " +
|
|
70
|
+
"`replace_all` is true. To delete text, pass an empty `new_string`.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
old_string: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Exact text to replace, verbatim from the document.",
|
|
77
|
+
},
|
|
78
|
+
new_string: { type: "string", description: "Replacement text (empty string to delete)." },
|
|
79
|
+
replace_all: { type: "boolean", description: "Replace every occurrence (default false)." },
|
|
80
|
+
},
|
|
81
|
+
required: ["old_string", "new_string"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "canvas_open",
|
|
86
|
+
description:
|
|
87
|
+
"Switch the mdinterface canvas to a different document so the user sees and edits it. " +
|
|
88
|
+
"Pass an absolute path to a local .md file (the file must already exist — write it " +
|
|
89
|
+
"first if needed). Use this to open a draft you just created, e.g. one pulled from " +
|
|
90
|
+
"Notion. Does not restart the session.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
path: { type: "string", description: "Absolute path to the .md file to open." },
|
|
95
|
+
},
|
|
96
|
+
required: ["path"],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// A regex that matches `str` literally EXCEPT runs of whitespace match any whitespace,
|
|
102
|
+
// so an edit succeeds even if the model got indentation / line-wrapping slightly off.
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} str
|
|
105
|
+
* @param {string} flags
|
|
106
|
+
* @returns {RegExp}
|
|
107
|
+
*/
|
|
108
|
+
function wsTolerantRegex(str, flags) {
|
|
109
|
+
const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
110
|
+
return new RegExp(escaped.replace(/\s+/g, "\\s+"), flags);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Line numbers of up to 5 exact matches, for a "not unique" hint.
|
|
114
|
+
function matchLines(content, str) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
let idx = content.indexOf(str);
|
|
117
|
+
while (idx !== -1 && lines.length < 5) {
|
|
118
|
+
lines.push(content.slice(0, idx).split("\n").length);
|
|
119
|
+
idx = content.indexOf(str, idx + str.length);
|
|
120
|
+
}
|
|
121
|
+
return lines.length ? ` (lines ${lines.join(", ")})` : "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// When nothing matches, point the model at the closest text so it fixes its next attempt
|
|
125
|
+
// in one try instead of guessing — the main cause of repeated failed edits.
|
|
126
|
+
function nearbyHint(content, oldStr) {
|
|
127
|
+
const norm = oldStr.replace(/\s+/g, " ").trim();
|
|
128
|
+
for (let len = Math.min(norm.length, 50); len >= 10; len -= 5) {
|
|
129
|
+
let m;
|
|
130
|
+
try {
|
|
131
|
+
m = wsTolerantRegex(norm.slice(0, len), "").exec(content);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (m) {
|
|
136
|
+
const line = content.slice(0, m.index).split("\n").length;
|
|
137
|
+
const text = (content.split("\n")[line - 1] || "").trim();
|
|
138
|
+
return ` Closest text is at line ${line}: «${text.slice(0, 90)}».`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Match (exact, then whitespace-tolerant) and apply — or, with dryRun, just validate and
|
|
145
|
+
// return the message that WOULD result without writing (used by the unit tests).
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} doc absolute path to the document to edit
|
|
148
|
+
* @param {string} old_string exact text to replace
|
|
149
|
+
* @param {string} new_string replacement text ("" to delete)
|
|
150
|
+
* @param {boolean} [replace_all] replace every occurrence instead of requiring uniqueness
|
|
151
|
+
* @param {boolean} [dryRun] validate only — do not write the file
|
|
152
|
+
* @returns {string} a human-readable result message
|
|
153
|
+
*/
|
|
154
|
+
function applyEdit(doc, old_string, new_string, replace_all, dryRun) {
|
|
155
|
+
const content = fs.readFileSync(doc, "utf8");
|
|
156
|
+
const base = path.basename(doc);
|
|
157
|
+
|
|
158
|
+
const exact = content.split(old_string).length - 1;
|
|
159
|
+
if (exact >= 1) {
|
|
160
|
+
if (exact > 1 && !replace_all)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`old_string matches ${exact} places${matchLines(content, old_string)} — add surrounding context to make it unique, or set replace_all: true.`
|
|
163
|
+
);
|
|
164
|
+
if (!dryRun)
|
|
165
|
+
fs.writeFileSync(
|
|
166
|
+
doc,
|
|
167
|
+
replace_all
|
|
168
|
+
? content.split(old_string).join(new_string)
|
|
169
|
+
: content.replace(old_string, () => new_string)
|
|
170
|
+
);
|
|
171
|
+
return replace_all ? `Replaced ${exact} occurrence(s) in ${base}.` : `Edited ${base}.`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const re = wsTolerantRegex(old_string, "g");
|
|
175
|
+
const fuzzy = (content.match(re) || []).length;
|
|
176
|
+
if (fuzzy >= 1) {
|
|
177
|
+
if (fuzzy > 1 && !replace_all)
|
|
178
|
+
throw new Error(
|
|
179
|
+
`old_string matches ${fuzzy} places (ignoring whitespace) — add surrounding context, or set replace_all: true.`
|
|
180
|
+
);
|
|
181
|
+
if (!dryRun)
|
|
182
|
+
fs.writeFileSync(
|
|
183
|
+
doc,
|
|
184
|
+
content.replace(re, () => new_string)
|
|
185
|
+
); // global re; fuzzy===1 unless replace_all
|
|
186
|
+
return `Edited ${base} (matched ignoring whitespace differences).`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error(
|
|
190
|
+
`old_string not found in ${base}.${nearbyHint(content, old_string)} ` +
|
|
191
|
+
`Copy the text verbatim from the document, including punctuation such as em dashes (—) and arrows.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function canvasEdit(args) {
|
|
196
|
+
const { old_string, new_string, replace_all } = args || {};
|
|
197
|
+
if (typeof old_string !== "string" || typeof new_string !== "string")
|
|
198
|
+
throw new Error("old_string and new_string are required strings.");
|
|
199
|
+
if (!old_string) throw new Error("old_string must not be empty.");
|
|
200
|
+
if (old_string === new_string) throw new Error("old_string and new_string are identical.");
|
|
201
|
+
|
|
202
|
+
const rt = runtime();
|
|
203
|
+
// The currently-open document — read from the runtime file so canvas_edit follows the
|
|
204
|
+
// file picker, falling back to the doc this server was launched with.
|
|
205
|
+
const doc = rt.doc ? path.resolve(rt.doc) : DOC;
|
|
206
|
+
if (!doc) throw new Error("No document is open.");
|
|
207
|
+
return applyEdit(doc, old_string, new_string, replace_all, false);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handle(msg) {
|
|
211
|
+
const { id, method, params } = msg;
|
|
212
|
+
if (method === "initialize") {
|
|
213
|
+
result(id, {
|
|
214
|
+
protocolVersion: params?.protocolVersion || "2025-06-18",
|
|
215
|
+
capabilities: { tools: {} },
|
|
216
|
+
serverInfo: { name: "mdinterface", version: "0.1.0" },
|
|
217
|
+
});
|
|
218
|
+
} else if (method === "tools/list") {
|
|
219
|
+
result(id, { tools: TOOLS });
|
|
220
|
+
} else if (method === "tools/call") {
|
|
221
|
+
const name = params?.name;
|
|
222
|
+
const tool = name === "canvas_edit" ? canvasEdit : name === "canvas_open" ? canvasOpen : null;
|
|
223
|
+
if (!tool) return rpcError(id, -32602, `Unknown tool: ${name}`);
|
|
224
|
+
// Tools are async; surface failures as isError results
|
|
225
|
+
// so the model can react and retry.
|
|
226
|
+
tool(params.arguments)
|
|
227
|
+
.then((text) => result(id, { content: [{ type: "text", text }] }))
|
|
228
|
+
.catch((e) =>
|
|
229
|
+
result(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true })
|
|
230
|
+
);
|
|
231
|
+
} else if (method === "ping") {
|
|
232
|
+
result(id, {});
|
|
233
|
+
} else if (id !== undefined) {
|
|
234
|
+
rpcError(id, -32601, `Method not found: ${method}`);
|
|
235
|
+
}
|
|
236
|
+
// notifications (no id), e.g. notifications/initialized — nothing to return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Run the stdio JSON-RPC loop only when invoked directly. When this file is `require`d
|
|
240
|
+
// (e.g. by the unit tests) the loop stays dormant and only the pure helpers are exposed.
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
let buf = "";
|
|
243
|
+
process.stdin.setEncoding("utf8");
|
|
244
|
+
process.stdin.on("data", (chunk) => {
|
|
245
|
+
buf += chunk;
|
|
246
|
+
while (true) {
|
|
247
|
+
const nl = buf.indexOf("\n");
|
|
248
|
+
if (nl === -1) break;
|
|
249
|
+
const line = buf.slice(0, nl).trim();
|
|
250
|
+
buf = buf.slice(nl + 1);
|
|
251
|
+
if (!line) continue;
|
|
252
|
+
let msg;
|
|
253
|
+
try {
|
|
254
|
+
msg = JSON.parse(line);
|
|
255
|
+
} catch {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
handle(msg);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (msg && msg.id !== undefined) rpcError(msg.id, -32603, e.message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
process.stdin.on("end", () => process.exit(0));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Exposed for unit tests; importing this module never starts the stdio server (see guard above).
|
|
269
|
+
module.exports = { applyEdit, wsTolerantRegex, matchLines, nearbyHint };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mdinterface",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A live markdown canvas wired to Claude Code. Highlight a passage to set both the context and the scope, ask, and Claude edits exactly that and leaves the rest alone.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kevin Sundstrom",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kevinsundstrom/mdinterface.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/kevinsundstrom/mdinterface#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kevinsundstrom/mdinterface/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"mdinterface": "server.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "bash start.sh",
|
|
21
|
+
"lint": "biome check .",
|
|
22
|
+
"format": "biome format --write .",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"claude",
|
|
32
|
+
"claude-code",
|
|
33
|
+
"markdown",
|
|
34
|
+
"canvas",
|
|
35
|
+
"live-preview",
|
|
36
|
+
"editor",
|
|
37
|
+
"terminal",
|
|
38
|
+
"mcp"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"server.js",
|
|
45
|
+
"mcp-server.js",
|
|
46
|
+
"access.js",
|
|
47
|
+
"start.sh",
|
|
48
|
+
"public/",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"express": "^4.19.0",
|
|
54
|
+
"node-pty": "^1.0.0",
|
|
55
|
+
"ws": "^8.17.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "^2.0.0",
|
|
59
|
+
"@types/express": "^4.17.21",
|
|
60
|
+
"@types/node": "^20.14.0",
|
|
61
|
+
"@types/ws": "^8.5.10",
|
|
62
|
+
"typescript": "^5.5.0"
|
|
63
|
+
}
|
|
64
|
+
}
|