drawio-mcp-server 2.1.1 → 2.3.0

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.
Files changed (38) hide show
  1. package/README.md +21 -68
  2. package/build/assets/auto-refresh.js +21 -0
  3. package/build/assets/auto-refresh.test.js +54 -0
  4. package/build/assets/downloader.js +4 -0
  5. package/build/assets/version.js +38 -0
  6. package/build/assets/version.test.js +26 -0
  7. package/build/documents-changed-broadcast.test.js +123 -0
  8. package/build/drawio-compat/log-report.js +21 -0
  9. package/build/drawio-compat/log-report.test.js +43 -0
  10. package/build/drawio-compat/matrix.js +14 -0
  11. package/build/index.js +152 -11
  12. package/build/install/config-io.js +21 -0
  13. package/build/install/config-io.test.js +37 -0
  14. package/build/install/hosts/claude-code.js +37 -0
  15. package/build/install/hosts/claude-code.test.js +47 -0
  16. package/build/install/hosts/claude-desktop.js +45 -0
  17. package/build/install/hosts/claude-desktop.test.js +57 -0
  18. package/build/install/hosts/codex.js +171 -0
  19. package/build/install/hosts/codex.test.js +124 -0
  20. package/build/install/hosts/index.js +15 -0
  21. package/build/install/hosts/opencode.js +48 -0
  22. package/build/install/hosts/opencode.test.js +60 -0
  23. package/build/install/hosts/zed.js +37 -0
  24. package/build/install/hosts/zed.test.js +47 -0
  25. package/build/install/index.js +170 -0
  26. package/build/install/index.test.js +115 -0
  27. package/build/install/install.integration.test.js +103 -0
  28. package/build/install/types.js +1 -0
  29. package/build/multi-transport.test.js +1 -0
  30. package/build/plugin/mcp-plugin.js +406 -89
  31. package/build/real-environment/import-export.test.js +107 -0
  32. package/build/stdio-shutdown.test.js +177 -0
  33. package/build/tool-registry.test.js +57 -0
  34. package/build/tools/index.js +4 -0
  35. package/build/tools/save-document.js +5 -0
  36. package/build/tools/set-document-title.js +12 -0
  37. package/build/vendored/compat/index.js +35 -0
  38. package/package.json +15 -10
@@ -0,0 +1,171 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { stringify } from "smol-toml";
6
+ export const codexAdapter = {
7
+ id: "codex",
8
+ displayName: "Codex CLI",
9
+ defaultPaths() {
10
+ return [join(homedir(), ".codex", "config.toml")];
11
+ },
12
+ async detect() {
13
+ return existsSync(codexAdapter.defaultPaths()[0]) ? "installed" : "absent";
14
+ },
15
+ async read(path) {
16
+ if (!existsSync(path))
17
+ return "";
18
+ return fs.readFile(path, "utf8");
19
+ },
20
+ merge(source, entry, name, { uninstall }) {
21
+ return mergeTomlBlock(source, name, uninstall ? null : blockBody(entry));
22
+ },
23
+ diffLabel() {
24
+ return codexAdapter.defaultPaths()[0];
25
+ },
26
+ };
27
+ function blockBody(entry) {
28
+ const table = {
29
+ command: entry.command,
30
+ args: entry.args,
31
+ };
32
+ // `smol-toml` renders arrays/inline-tables with padding spaces
33
+ // (`[ "a", "b" ]`); normalize to the compact style used elsewhere in this
34
+ // project's generated configs (`["a", "b"]`).
35
+ const base = stringify(table)
36
+ .trim()
37
+ .replace(/\[ /g, "[")
38
+ .replace(/ \]/g, "]");
39
+ const envKeys = Object.keys(entry.env);
40
+ if (envKeys.length === 0)
41
+ return base;
42
+ // `smol-toml`'s `stringify` renders a nested object as a `[env]` sub-table
43
+ // header on its own line, which `removeBlock` (below) mistakes for a
44
+ // sibling top-level table -- corrupting idempotent merges and leaking
45
+ // secrets on uninstall (the header + its keys survive as an orphan
46
+ // block). Render `env` as a single-line inline table instead, which stays
47
+ // inside this block and cannot be mistaken for a new `[table]` header.
48
+ const envLine = `env = { ${envKeys
49
+ .map((k) => `${tomlKey(k)} = ${tomlString(entry.env[k])}`)
50
+ .join(", ")} }`;
51
+ return `${base}\n${envLine}`;
52
+ }
53
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/;
54
+ function tomlKey(key) {
55
+ return BARE_KEY.test(key) ? key : tomlString(key);
56
+ }
57
+ function tomlString(value) {
58
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
59
+ }
60
+ /**
61
+ * Insert, replace, or remove a single TOML table (identified by its dotted
62
+ * `key`, e.g. `mcp_servers.drawio`) in `source`, leaving every other byte of
63
+ * the file untouched.
64
+ *
65
+ * `smol-toml`'s `parse` -> `stringify` round-trip (as sketched in the task
66
+ * brief) discards *all* comments and reformats the entire document -- not
67
+ * just the table being edited. That fails round-trip expectations for any
68
+ * config a human may have hand-edited. Splicing the target table's text
69
+ * directly avoids touching unrelated tables, comments, or blank-line layout.
70
+ */
71
+ function mergeTomlBlock(source, name, body) {
72
+ const header = `[mcp_servers.${name}]`;
73
+ const lines = source.length > 0 ? source.split("\n") : [];
74
+ assertNoInlineInParentForm(lines, name);
75
+ const spliced = removeBlock(lines, name);
76
+ if (body === null)
77
+ return spliced.join("\n");
78
+ return appendBlock(spliced, header, body).join("\n");
79
+ }
80
+ /**
81
+ * Does `line` open the `[mcp_servers.<name>]` table, tolerating TOML-legal
82
+ * variants a hand-authored config might use: a quoted key segment
83
+ * (`[mcp_servers."drawio"]`) and/or whitespace around the `.` separator
84
+ * (`[mcp_servers . drawio]`)?
85
+ */
86
+ function isTargetHeader(line, name) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
89
+ return false;
90
+ const inner = trimmed.slice(1, -1).trim();
91
+ const segments = inner.split(/\s*\.\s*/).map(unquoteTomlKey);
92
+ return (segments.length === 2 &&
93
+ segments[0] === "mcp_servers" &&
94
+ segments[1] === name);
95
+ }
96
+ function unquoteTomlKey(segment) {
97
+ const s = segment.trim();
98
+ if (s.length >= 2) {
99
+ const first = s[0];
100
+ const last = s[s.length - 1];
101
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
102
+ return s.slice(1, -1);
103
+ }
104
+ }
105
+ return s;
106
+ }
107
+ /**
108
+ * The inline-in-parent form (`[mcp_servers]` header followed by a bare
109
+ * `<name> = { ... }` assignment) is a different TOML structure from a
110
+ * nested `[mcp_servers.<name>]` table, and this tool's block-splice merge
111
+ * doesn't understand it. Rather than silently corrupt a hand-authored
112
+ * config (e.g. by appending a second, conflicting `drawio` entry), fail
113
+ * loudly and tell the user how to fix it.
114
+ */
115
+ function assertNoInlineInParentForm(lines, name) {
116
+ const startIdx = lines.findIndex((line) => {
117
+ const trimmed = line.trim();
118
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
119
+ return false;
120
+ return trimmed.slice(1, -1).trim() === "mcp_servers";
121
+ });
122
+ if (startIdx === -1)
123
+ return;
124
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ const assignment = new RegExp(`^\\s*(?:${escapedName}|"${escapedName}"|'${escapedName}')\\s*=`);
126
+ for (let i = startIdx + 1; i < lines.length; i++) {
127
+ const line = lines[i];
128
+ if (/^\s*\[[A-Za-z_"]/.test(line))
129
+ break;
130
+ if (assignment.test(line)) {
131
+ throw new Error(`Found "${name}" defined inline under [mcp_servers] (e.g. \`${name} = { ... }\`), ` +
132
+ `which this tool doesn't support. Please move the entry into a ` +
133
+ `[mcp_servers.${name}] table, or use --config-path to point at a different config file.`);
134
+ }
135
+ }
136
+ }
137
+ function removeBlock(lines, name) {
138
+ const startIdx = lines.findIndex((line) => isTargetHeader(line, name));
139
+ if (startIdx === -1)
140
+ return lines;
141
+ let endIdx = lines.length;
142
+ for (let i = startIdx + 1; i < lines.length; i++) {
143
+ if (/^\s*\[[A-Za-z_"]/.test(lines[i])) {
144
+ endIdx = i;
145
+ break;
146
+ }
147
+ }
148
+ // Drop trailing blank lines that belong to the removed block.
149
+ while (endIdx > startIdx + 1 && lines[endIdx - 1].trim() === "") {
150
+ endIdx--;
151
+ }
152
+ const before = lines.slice(0, startIdx);
153
+ // Drop the single blank separator line preceding the block, if any, so
154
+ // removal doesn't leave a double blank line behind.
155
+ if (before.length > 0 && before[before.length - 1].trim() === "") {
156
+ before.pop();
157
+ }
158
+ const after = lines.slice(endIdx);
159
+ return [...before, ...after];
160
+ }
161
+ function appendBlock(lines, header, body) {
162
+ const bodyLines = body.split("\n");
163
+ // Drop the single trailing empty element representing the file's final
164
+ // newline, so we don't accumulate blank lines across repeated merges.
165
+ const trimmed = lines.length > 0 && lines[lines.length - 1] === ""
166
+ ? lines.slice(0, -1)
167
+ : lines;
168
+ if (trimmed.length === 0)
169
+ return [header, ...bodyLines, ""];
170
+ return [...trimmed, "", header, ...bodyLines, ""];
171
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { codexAdapter } from "./codex.js";
3
+ const ENTRY = {
4
+ command: "npx",
5
+ args: ["-y", "drawio-mcp-server", "--editor"],
6
+ env: {},
7
+ transport: "stdio",
8
+ };
9
+ describe("codexAdapter.merge", () => {
10
+ it("inserts [mcp_servers.drawio] into empty file", () => {
11
+ const out = codexAdapter.merge("", ENTRY, "drawio", { uninstall: false });
12
+ expect(out).toContain("[mcp_servers.drawio]");
13
+ expect(out).toContain('command = "npx"');
14
+ expect(out).toContain('args = ["-y", "drawio-mcp-server", "--editor"]');
15
+ });
16
+ it("preserves existing entries and comments", () => {
17
+ const source = [
18
+ "# my codex config",
19
+ "[mcp_servers.other]",
20
+ 'command = "other-server"',
21
+ "",
22
+ ].join("\n");
23
+ const out = codexAdapter.merge(source, ENTRY, "drawio", {
24
+ uninstall: false,
25
+ });
26
+ expect(out).toContain("# my codex config");
27
+ expect(out).toContain("[mcp_servers.other]");
28
+ expect(out).toContain('command = "other-server"');
29
+ expect(out).toContain("[mcp_servers.drawio]");
30
+ });
31
+ it("updates existing drawio entry idempotently", () => {
32
+ const source = ["[mcp_servers.drawio]", 'command = "old"', ""].join("\n");
33
+ const out1 = codexAdapter.merge(source, ENTRY, "drawio", {
34
+ uninstall: false,
35
+ });
36
+ const out2 = codexAdapter.merge(out1, ENTRY, "drawio", {
37
+ uninstall: false,
38
+ });
39
+ expect(out2).toBe(out1);
40
+ expect(out1).toContain('command = "npx"');
41
+ expect(out1).not.toContain('command = "old"');
42
+ });
43
+ it("removes entry on uninstall and leaves other blocks intact", () => {
44
+ const source = [
45
+ "[mcp_servers.other]",
46
+ 'command = "other"',
47
+ "",
48
+ "[mcp_servers.drawio]",
49
+ 'command = "npx"',
50
+ "",
51
+ ].join("\n");
52
+ const out = codexAdapter.merge(source, ENTRY, "drawio", {
53
+ uninstall: true,
54
+ });
55
+ expect(out).toContain("[mcp_servers.other]");
56
+ expect(out).not.toContain("[mcp_servers.drawio]");
57
+ });
58
+ it("round-trips non-empty env idempotently and removes env on uninstall", () => {
59
+ const withEnv = {
60
+ command: "npx",
61
+ args: ["-y", "drawio-mcp-server", "--editor"],
62
+ env: { FOO: "bar", HELLO: "world" },
63
+ transport: "stdio",
64
+ };
65
+ const first = codexAdapter.merge("", withEnv, "drawio", {
66
+ uninstall: false,
67
+ });
68
+ expect(first).toContain("[mcp_servers.drawio]");
69
+ expect(first).toContain("env = {");
70
+ expect(first).toContain('FOO = "bar"');
71
+ expect(first).toContain('HELLO = "world"');
72
+ const second = codexAdapter.merge(first, withEnv, "drawio", {
73
+ uninstall: false,
74
+ });
75
+ expect(second).toBe(first);
76
+ const removed = codexAdapter.merge(first, withEnv, "drawio", {
77
+ uninstall: true,
78
+ });
79
+ expect(removed).not.toContain("[mcp_servers.drawio]");
80
+ expect(removed).not.toContain("FOO");
81
+ expect(removed).not.toContain("HELLO");
82
+ expect(removed).not.toContain("[env]");
83
+ expect(removed).not.toContain("bar");
84
+ });
85
+ it("recognizes quoted-key header form", () => {
86
+ const src = '[mcp_servers."drawio"]\ncommand = "old"\n';
87
+ const out = codexAdapter.merge(src, ENTRY, "drawio", { uninstall: false });
88
+ // Should update in place, not append a duplicate block.
89
+ expect((out.match(/\[mcp_servers/g) ?? []).length).toBe(1);
90
+ expect(out).toContain('command = "npx"');
91
+ });
92
+ it("recognizes whitespace-around-dot header form", () => {
93
+ const src = '[mcp_servers . drawio]\ncommand = "old"\n';
94
+ const out = codexAdapter.merge(src, ENTRY, "drawio", { uninstall: false });
95
+ expect((out.match(/\[mcp_servers/g) ?? []).length).toBe(1);
96
+ expect(out).toContain('command = "npx"');
97
+ });
98
+ it("throws on unsupported inline-in-parent form", () => {
99
+ const src = '[mcp_servers]\ndrawio = { command = "old" }\n';
100
+ expect(() => codexAdapter.merge(src, ENTRY, "drawio", { uninstall: false })).toThrow(/inline-in-parent|move.*mcp_servers\.drawio/i);
101
+ });
102
+ it("does not confuse an indented [ array element with a sibling table header", () => {
103
+ // Not real drawio schema, but proves robustness for future keys.
104
+ const src = [
105
+ "[mcp_servers.drawio]",
106
+ 'command = "old"',
107
+ "args = [",
108
+ " [1, 2, 3],",
109
+ "]",
110
+ "",
111
+ "[mcp_servers.other]",
112
+ 'command = "x"',
113
+ ].join("\n");
114
+ const out = codexAdapter.merge(src, ENTRY, "drawio", { uninstall: false });
115
+ expect(out).toContain("[mcp_servers.other]");
116
+ expect(out).toContain('command = "x"');
117
+ });
118
+ });
119
+ describe("codexAdapter.defaultPaths", () => {
120
+ it("points at ~/.codex/config.toml", () => {
121
+ const paths = codexAdapter.defaultPaths();
122
+ expect(paths[0]).toMatch(/\.codex\/config\.toml$/);
123
+ });
124
+ });
@@ -0,0 +1,15 @@
1
+ import { claudeCodeAdapter } from "./claude-code.js";
2
+ import { claudeDesktopAdapter } from "./claude-desktop.js";
3
+ import { codexAdapter } from "./codex.js";
4
+ import { opencodeAdapter } from "./opencode.js";
5
+ import { zedAdapter } from "./zed.js";
6
+ export const HOST_ADAPTERS = {
7
+ [codexAdapter.id]: codexAdapter,
8
+ [zedAdapter.id]: zedAdapter,
9
+ [opencodeAdapter.id]: opencodeAdapter,
10
+ [claudeDesktopAdapter.id]: claudeDesktopAdapter,
11
+ [claudeCodeAdapter.id]: claudeCodeAdapter,
12
+ };
13
+ export function listHostIds() {
14
+ return Object.keys(HOST_ADAPTERS);
15
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { applyEdits, modify, parse } from "jsonc-parser";
5
+ export const opencodeAdapter = {
6
+ id: "opencode",
7
+ displayName: "OpenCode",
8
+ defaultPaths() {
9
+ const cwd = process.cwd();
10
+ const local = ["opencode.json", "opencode.jsonc"]
11
+ .map((f) => join(cwd, f))
12
+ .filter(existsSync);
13
+ const global = join(homedir(), ".config", "opencode", "opencode.json");
14
+ return local.length > 0 ? [...local, global] : [global];
15
+ },
16
+ async detect() {
17
+ return existsSync(opencodeAdapter.defaultPaths()[0])
18
+ ? "installed"
19
+ : "absent";
20
+ },
21
+ async read(path) {
22
+ return existsSync(path) ? fs.readFile(path, "utf8") : "";
23
+ },
24
+ merge(source, entry, name, { uninstall }) {
25
+ const text = source.trim() === "" ? "{}\n" : source;
26
+ const parsed = parse(text);
27
+ if (!parsed || typeof parsed !== "object")
28
+ throw new Error("opencode config is not a JSON object");
29
+ const edits = uninstall
30
+ ? modify(text, ["mcp", name], undefined, {
31
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
32
+ })
33
+ : modify(text, ["mcp", name], toValue(entry), {
34
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
35
+ });
36
+ return applyEdits(text, edits);
37
+ },
38
+ diffLabel() {
39
+ return opencodeAdapter.defaultPaths()[0];
40
+ },
41
+ };
42
+ function toValue(entry) {
43
+ return {
44
+ type: "local",
45
+ command: [entry.command, ...entry.args],
46
+ enabled: true,
47
+ };
48
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { opencodeAdapter } from "./opencode.js";
6
+ const ENTRY = {
7
+ command: "npx",
8
+ args: ["-y", "drawio-mcp-server", "--editor"],
9
+ env: {},
10
+ transport: "stdio",
11
+ };
12
+ describe("opencodeAdapter.merge", () => {
13
+ it("adds mcp.drawio entry with type: local", () => {
14
+ const out = opencodeAdapter.merge("", ENTRY, "drawio", {
15
+ uninstall: false,
16
+ });
17
+ const parsed = JSON.parse(out);
18
+ expect(parsed.mcp.drawio).toEqual({
19
+ type: "local",
20
+ command: ["npx", "-y", "drawio-mcp-server", "--editor"],
21
+ enabled: true,
22
+ });
23
+ });
24
+ it("preserves the $schema field", () => {
25
+ const source = JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: {} }, null, 2);
26
+ const out = opencodeAdapter.merge(source, ENTRY, "drawio", {
27
+ uninstall: false,
28
+ });
29
+ const parsed = JSON.parse(out);
30
+ expect(parsed["$schema"]).toBe("https://opencode.ai/config.json");
31
+ });
32
+ it("uninstall removes the entry", () => {
33
+ const source = JSON.stringify({
34
+ mcp: { drawio: { type: "local", command: ["old"] } },
35
+ });
36
+ const out = opencodeAdapter.merge(source, ENTRY, "drawio", {
37
+ uninstall: true,
38
+ });
39
+ expect(JSON.parse(out).mcp).toEqual({});
40
+ });
41
+ });
42
+ describe("opencodeAdapter.defaultPaths", () => {
43
+ let dir;
44
+ beforeEach(() => {
45
+ dir = mkdtempSync(join(tmpdir(), "opencode-"));
46
+ process.chdir(dir);
47
+ });
48
+ afterEach(() => {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ });
51
+ it("returns project config first when present", () => {
52
+ writeFileSync(join(dir, "opencode.json"), "{}");
53
+ const paths = opencodeAdapter.defaultPaths();
54
+ expect(paths[0]).toBe(join(dir, "opencode.json"));
55
+ });
56
+ it("falls back to global ~/.config/opencode/opencode.json otherwise", () => {
57
+ const paths = opencodeAdapter.defaultPaths();
58
+ expect(paths[paths.length - 1]).toMatch(/\.config\/opencode\/opencode\.json$/);
59
+ });
60
+ });
@@ -0,0 +1,37 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { applyEdits, modify, parse } from "jsonc-parser";
5
+ export const zedAdapter = {
6
+ id: "zed",
7
+ displayName: "Zed",
8
+ defaultPaths() {
9
+ return [join(homedir(), ".config", "zed", "settings.json")];
10
+ },
11
+ async detect() {
12
+ return existsSync(zedAdapter.defaultPaths()[0]) ? "installed" : "absent";
13
+ },
14
+ async read(path) {
15
+ return existsSync(path) ? fs.readFile(path, "utf8") : "";
16
+ },
17
+ merge(source, entry, name, { uninstall }) {
18
+ let text = source.trim() === "" ? "{}\n" : source;
19
+ const parsed = parse(text);
20
+ if (!parsed || typeof parsed !== "object")
21
+ text = "{}\n";
22
+ const edits = uninstall
23
+ ? modify(text, ["context_servers", name], undefined, {
24
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
25
+ })
26
+ : modify(text, ["context_servers", name], toValue(entry), {
27
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
28
+ });
29
+ return applyEdits(text, edits);
30
+ },
31
+ diffLabel() {
32
+ return zedAdapter.defaultPaths()[0];
33
+ },
34
+ };
35
+ function toValue(entry) {
36
+ return { command: entry.command, args: entry.args, env: entry.env };
37
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { zedAdapter } from "./zed.js";
3
+ const ENTRY = {
4
+ command: "npx",
5
+ args: ["-y", "drawio-mcp-server", "--editor"],
6
+ env: {},
7
+ transport: "stdio",
8
+ };
9
+ describe("zedAdapter.merge", () => {
10
+ it("adds context_servers.drawio to empty file", () => {
11
+ const out = zedAdapter.merge("", ENTRY, "drawio", { uninstall: false });
12
+ const parsed = JSON.parse(out);
13
+ expect(parsed.context_servers.drawio).toEqual({
14
+ command: "npx",
15
+ args: ["-y", "drawio-mcp-server", "--editor"],
16
+ env: {},
17
+ });
18
+ });
19
+ it("preserves other keys", () => {
20
+ const source = JSON.stringify({ theme: "One Dark", context_servers: { other: { command: "x" } } }, null, 2);
21
+ const out = zedAdapter.merge(source, ENTRY, "drawio", { uninstall: false });
22
+ const parsed = JSON.parse(out);
23
+ expect(parsed.theme).toBe("One Dark");
24
+ expect(parsed.context_servers.other).toEqual({ command: "x" });
25
+ expect(parsed.context_servers.drawio.command).toBe("npx");
26
+ });
27
+ it("uninstall removes the entry", () => {
28
+ const source = JSON.stringify({
29
+ context_servers: { drawio: { command: "old" } },
30
+ });
31
+ const out = zedAdapter.merge(source, ENTRY, "drawio", { uninstall: true });
32
+ expect(JSON.parse(out).context_servers).toEqual({});
33
+ });
34
+ it("uninstall on missing entry is a no-op", () => {
35
+ const source = JSON.stringify({
36
+ context_servers: { other: { command: "x" } },
37
+ });
38
+ const out = zedAdapter.merge(source, ENTRY, "drawio", { uninstall: true });
39
+ expect(JSON.parse(out).context_servers.other).toEqual({ command: "x" });
40
+ });
41
+ });
42
+ describe("zedAdapter.defaultPaths", () => {
43
+ it("points at ~/.config/zed/settings.json", () => {
44
+ const [p] = zedAdapter.defaultPaths();
45
+ expect(p).toMatch(/\.config\/zed\/settings\.json$/);
46
+ });
47
+ });
@@ -0,0 +1,170 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import { HOST_ADAPTERS } from "./hosts/index.js";
4
+ import { atomicWrite, ensureBackup, unifiedDiff } from "./config-io.js";
5
+ const SUPPORTED_HOSTS = new Set([
6
+ "claude-code",
7
+ "claude-desktop",
8
+ "codex",
9
+ "zed",
10
+ "opencode",
11
+ "all",
12
+ ]);
13
+ export function parseArgs(argv) {
14
+ if (argv.length === 0) {
15
+ throw new Error("host required — usage: drawio-mcp-server install <host> [options]");
16
+ }
17
+ const host = argv[0];
18
+ if (!SUPPORTED_HOSTS.has(host)) {
19
+ throw new Error(`unknown host: ${host} (supported: ${[...SUPPORTED_HOSTS].join(", ")})`);
20
+ }
21
+ const opts = {
22
+ host,
23
+ name: "drawio",
24
+ editor: true,
25
+ httpPort: 3000,
26
+ extraArgs: [],
27
+ env: {},
28
+ configPath: undefined,
29
+ configPathByHost: {},
30
+ print: false,
31
+ dryRun: false,
32
+ uninstall: false,
33
+ yes: false,
34
+ };
35
+ let i = 1;
36
+ while (i < argv.length) {
37
+ const flag = argv[i];
38
+ switch (flag) {
39
+ case "--name":
40
+ opts.name = argv[++i];
41
+ break;
42
+ case "--editor":
43
+ opts.editor = true;
44
+ break;
45
+ case "--no-editor":
46
+ opts.editor = false;
47
+ break;
48
+ case "--http-port":
49
+ opts.httpPort = Number.parseInt(argv[++i], 10);
50
+ break;
51
+ case "--extra-arg":
52
+ opts.extraArgs.push(argv[++i]);
53
+ break;
54
+ case "--env": {
55
+ const kv = argv[++i];
56
+ const eq = kv.indexOf("=");
57
+ if (eq <= 0)
58
+ throw new Error(`invalid --env: expected KEY=VALUE, got ${kv}`);
59
+ opts.env[kv.slice(0, eq)] = kv.slice(eq + 1);
60
+ break;
61
+ }
62
+ case "--config-path": {
63
+ const raw = argv[++i];
64
+ const eq = raw.indexOf("=");
65
+ if (eq > 0) {
66
+ opts.configPathByHost[raw.slice(0, eq)] = raw.slice(eq + 1);
67
+ }
68
+ else {
69
+ opts.configPath = raw;
70
+ }
71
+ break;
72
+ }
73
+ case "--print":
74
+ opts.print = true;
75
+ break;
76
+ case "--dry-run":
77
+ opts.dryRun = true;
78
+ break;
79
+ case "--uninstall":
80
+ opts.uninstall = true;
81
+ break;
82
+ case "--yes":
83
+ opts.yes = true;
84
+ break;
85
+ default:
86
+ throw new Error(`unknown option: ${flag}`);
87
+ }
88
+ i++;
89
+ }
90
+ return opts;
91
+ }
92
+ export function buildEntry(opts) {
93
+ const args = ["-y", "drawio-mcp-server"];
94
+ if (opts.editor)
95
+ args.push("--editor");
96
+ if (opts.httpPort !== 3000)
97
+ args.push("--http-port", String(opts.httpPort));
98
+ for (const a of opts.extraArgs)
99
+ args.push(a);
100
+ return { command: "npx", args, env: opts.env, transport: "stdio" };
101
+ }
102
+ export async function runInstall(argv) {
103
+ let opts;
104
+ try {
105
+ opts = parseArgs(argv);
106
+ }
107
+ catch (err) {
108
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
109
+ return 1;
110
+ }
111
+ if (opts.host === "all") {
112
+ return applyAll(opts);
113
+ }
114
+ const adapter = HOST_ADAPTERS[opts.host];
115
+ if (!adapter) {
116
+ process.stderr.write(`unsupported host: ${opts.host}\n`);
117
+ return 1;
118
+ }
119
+ return applySingle(adapter, opts);
120
+ }
121
+ async function applyAll(opts) {
122
+ let exitCode = 0;
123
+ for (const [id, adapter] of Object.entries(HOST_ADAPTERS)) {
124
+ const detect = await adapter.detect();
125
+ const explicit = opts.configPathByHost[id];
126
+ if (detect === "absent" && !explicit) {
127
+ process.stderr.write(`skip ${id}: not installed\n`);
128
+ continue;
129
+ }
130
+ const perHostOpts = {
131
+ ...opts,
132
+ host: id,
133
+ configPath: explicit ?? opts.configPath,
134
+ };
135
+ const code = await applySingle(adapter, perHostOpts);
136
+ if (code !== 0)
137
+ exitCode = code;
138
+ }
139
+ return exitCode;
140
+ }
141
+ async function applySingle(adapter, opts) {
142
+ const target = opts.configPath ?? adapter.defaultPaths()[0];
143
+ const source = existsSync(target) ? await fs.readFile(target, "utf8") : "";
144
+ const entry = buildEntry(opts);
145
+ const next = adapter.merge(source, entry, opts.name, {
146
+ uninstall: opts.uninstall,
147
+ });
148
+ if (opts.print) {
149
+ process.stdout.write(next.endsWith("\n") ? next : `${next}\n`);
150
+ return 0;
151
+ }
152
+ if (source === next) {
153
+ process.stderr.write(`no change: ${target}\n`);
154
+ return 0;
155
+ }
156
+ const diff = unifiedDiff(source, next, target);
157
+ if (opts.dryRun) {
158
+ process.stderr.write(diff);
159
+ return 0;
160
+ }
161
+ if (existsSync(target) && !opts.yes) {
162
+ process.stderr.write(diff);
163
+ process.stderr.write(`\nRe-run with --yes to apply changes.\n`);
164
+ return 4;
165
+ }
166
+ await ensureBackup(target);
167
+ await atomicWrite(target, next);
168
+ process.stderr.write(`updated: ${target}\n`);
169
+ return 0;
170
+ }