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.
- package/README.md +21 -68
- package/build/assets/auto-refresh.js +21 -0
- package/build/assets/auto-refresh.test.js +54 -0
- package/build/assets/downloader.js +4 -0
- package/build/assets/version.js +38 -0
- package/build/assets/version.test.js +26 -0
- package/build/documents-changed-broadcast.test.js +123 -0
- package/build/drawio-compat/log-report.js +21 -0
- package/build/drawio-compat/log-report.test.js +43 -0
- package/build/drawio-compat/matrix.js +14 -0
- package/build/index.js +152 -11
- package/build/install/config-io.js +21 -0
- package/build/install/config-io.test.js +37 -0
- package/build/install/hosts/claude-code.js +37 -0
- package/build/install/hosts/claude-code.test.js +47 -0
- package/build/install/hosts/claude-desktop.js +45 -0
- package/build/install/hosts/claude-desktop.test.js +57 -0
- package/build/install/hosts/codex.js +171 -0
- package/build/install/hosts/codex.test.js +124 -0
- package/build/install/hosts/index.js +15 -0
- package/build/install/hosts/opencode.js +48 -0
- package/build/install/hosts/opencode.test.js +60 -0
- package/build/install/hosts/zed.js +37 -0
- package/build/install/hosts/zed.test.js +47 -0
- package/build/install/index.js +170 -0
- package/build/install/index.test.js +115 -0
- package/build/install/install.integration.test.js +103 -0
- package/build/install/types.js +1 -0
- package/build/multi-transport.test.js +1 -0
- package/build/plugin/mcp-plugin.js +406 -89
- package/build/real-environment/import-export.test.js +107 -0
- package/build/stdio-shutdown.test.js +177 -0
- package/build/tool-registry.test.js +57 -0
- package/build/tools/index.js +4 -0
- package/build/tools/save-document.js +5 -0
- package/build/tools/set-document-title.js +12 -0
- package/build/vendored/compat/index.js +35 -0
- 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
|
+
}
|