drawio-mcp-server 2.2.0 → 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/index.js +41 -2
- 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 +84 -0
- package/build/real-environment/import-export.test.js +107 -0
- package/build/stdio-shutdown.test.js +28 -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/package.json +10 -6
package/README.md
CHANGED
|
@@ -9,12 +9,16 @@ Let's do some Vibe Diagramming with the most wide-spread diagramming tool called
|
|
|
9
9
|
|
|
10
10
|
## Key Highlights
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
12
|
+
- Claude Code plugin: `/plugin marketplace add lgazo/drawio-mcp-server` then `/plugin install drawio`, with `/drawio-open` and `/drawio-status` slash commands 
|
|
13
|
+
- `npx drawio-mcp-server install <host>` writes the MCP config for `claude-code`, `claude-desktop`, `codex`, `zed`, `opencode`, or `all` 
|
|
14
|
+
- Document persistence tools: `set-document-title` and `save-document` 
|
|
15
|
+
- Draw.io library API version compatibility mechanism 
|
|
16
|
+
- Enable Draw.io MCP in IFrames
|
|
17
|
+
- AWS, GCP, Azure, Cisco19, and CiscoSafe stencils auto-discovered at runtime from drawio's sidebar
|
|
18
|
+
- Multi-document targeting with `list-documents` and `target_document` selectors for multi-tab workflows
|
|
19
|
+
- Multi-page targeting with required `target_page` selectors for page-scoped tools
|
|
20
|
+
- Per-document FIFO serialization for live operations, so multiple agents can work on different files safely
|
|
21
|
+
- Page management tools: `list-pages`, `get-current-page`, `create-page`, `copy-page`, `rename-page`
|
|
18
22
|
- Import, embed, or expand [Mermaid](https://mermaid.js.org/) diagrams 
|
|
19
23
|
- Firefox support is back, TLS mode is necessary 
|
|
20
24
|
- Server supports TLS mode and optionally generates self-signed certificates 
|
|
@@ -58,10 +62,15 @@ No additional requirements - runs out of the box with `--editor` flag.
|
|
|
58
62
|
|
|
59
63
|
### 1. Configure your MCP host
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
**Fastest path:**
|
|
66
|
+
|
|
67
|
+
- **Claude Code:** `/plugin marketplace add lgazo/drawio-mcp-server` then `/plugin install drawio`.
|
|
68
|
+
- **Any other host:** `npx drawio-mcp-server install <host>` where `<host>` is `claude-code`, `claude-desktop`, `codex`, `zed`, `opencode`, or `all`.
|
|
69
|
+
|
|
70
|
+
See [docs/PLUGINS.md](./docs/PLUGINS.md) for the full reference, flags, and uninstall, and [docs/release.md](./docs/release.md) for how releases are cut.
|
|
62
71
|
|
|
63
72
|
<details>
|
|
64
|
-
<summary>Claude Desktop</summary>
|
|
73
|
+
<summary>Manual install (JSON snippet, Claude Desktop example)</summary>
|
|
65
74
|
|
|
66
75
|
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
67
76
|
|
|
@@ -75,63 +84,7 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
```
|
|
78
|
-
</details>
|
|
79
|
-
|
|
80
|
-
<details>
|
|
81
|
-
<summary>Claude Code</summary>
|
|
82
|
-
|
|
83
|
-
```sh
|
|
84
|
-
claude mcp add-json drawio '{"type":"stdio","command":"npx","args":["-y","drawio-mcp-server","--editor"]}'
|
|
85
|
-
```
|
|
86
|
-
</details>
|
|
87
|
-
|
|
88
|
-
<details>
|
|
89
|
-
<summary>Zed</summary>
|
|
90
|
-
|
|
91
|
-
Add to `~/.config/zed/settings.json`:
|
|
92
|
-
|
|
93
|
-
```json
|
|
94
|
-
{
|
|
95
|
-
"context_servers": {
|
|
96
|
-
"drawio": {
|
|
97
|
-
"command": "npx",
|
|
98
|
-
"args": ["-y", "drawio-mcp-server", "--editor"],
|
|
99
|
-
"env": {}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
```
|
|
104
|
-
</details>
|
|
105
|
-
|
|
106
|
-
<details>
|
|
107
|
-
<summary>Codex</summary>
|
|
108
|
-
|
|
109
|
-
Edit `~/.codex/config.toml`:
|
|
110
|
-
|
|
111
|
-
```toml
|
|
112
|
-
[mcp_servers.drawio]
|
|
113
|
-
command = "npx"
|
|
114
|
-
args = ["-y", "drawio-mcp-server", "--editor"]
|
|
115
|
-
```
|
|
116
|
-
</details>
|
|
117
87
|
|
|
118
|
-
<details>
|
|
119
|
-
<summary>OpenCode</summary>
|
|
120
|
-
|
|
121
|
-
Add to `opencode.json` in your project root or `~/.config/opencode/opencode.json`:
|
|
122
|
-
|
|
123
|
-
```jsonc
|
|
124
|
-
{
|
|
125
|
-
"$schema": "https://opencode.ai/config.json",
|
|
126
|
-
"mcp": {
|
|
127
|
-
"drawio": {
|
|
128
|
-
"type": "local",
|
|
129
|
-
"command": ["npx", "-y", "drawio-mcp-server", "--editor"],
|
|
130
|
-
"enabled": true
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
88
|
</details>
|
|
136
89
|
|
|
137
90
|
For other MCP clients and detailed configuration (including pnpm options), see [Configuration](./CONFIG.md).
|
|
@@ -223,11 +176,11 @@ See [DESKTOP.md](./DESKTOP.md) for the full setup steps and the current limitati
|
|
|
223
176
|
|
|
224
177
|
## Star History
|
|
225
178
|
|
|
226
|
-
<a href="https://star-history.
|
|
179
|
+
<a href="https://star-history.dera.page/#lgazo/drawio-mcp-server&type=Date">
|
|
227
180
|
<picture>
|
|
228
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://
|
|
229
|
-
<source media="(prefers-color-scheme: light)" srcset="https://
|
|
230
|
-
<img alt="Star History Chart" src="https://
|
|
181
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date&theme=dark" />
|
|
182
|
+
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date" />
|
|
183
|
+
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date" />
|
|
231
184
|
</picture>
|
|
232
185
|
</a>
|
|
233
186
|
|
package/build/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { readFileSync, existsSync, statSync, readdirSync, realpathSync, } from "node:fs";
|
|
14
14
|
import { WebSocket, WebSocketServer } from "ws";
|
|
15
|
-
const VERSION = process.env.npm_package_version ?? "2.
|
|
15
|
+
const VERSION = process.env.npm_package_version ?? "2.3.0";
|
|
16
16
|
import { buildConfig, defaultConfig, hasFlag, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
|
|
17
17
|
import { installDesktopPlugin } from "./install-desktop-plugin.js";
|
|
18
18
|
import { bus_reply_stream, bus_request_stream, } from "./types.js";
|
|
@@ -28,6 +28,17 @@ import { target_document_field } from "./tools/shared.js";
|
|
|
28
28
|
import { resolveTlsMaterial } from "./tls/index.js";
|
|
29
29
|
import { handleCompatReport } from "./drawio-compat/log-report.js";
|
|
30
30
|
const fatalLog = create_console_logger();
|
|
31
|
+
if (process.argv[2] === "install") {
|
|
32
|
+
const { runInstall } = await import("./install/index.js");
|
|
33
|
+
try {
|
|
34
|
+
const code = await runInstall(process.argv.slice(3));
|
|
35
|
+
process.exit(code);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
fatalLog.log("error", err instanceof Error ? err.message : String(err));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
/**
|
|
32
43
|
* Display help message and exit
|
|
33
44
|
*/
|
|
@@ -223,6 +234,20 @@ async function startHttpServer(createServer, disposeMcpServer, log, httpPort, co
|
|
|
223
234
|
}
|
|
224
235
|
: {}),
|
|
225
236
|
});
|
|
237
|
+
// `serve()` calls `listen()` synchronously; wait for the outcome so a failed
|
|
238
|
+
// bind surfaces as a clean, fatal error instead of an unhandled 'error'
|
|
239
|
+
// event (or, worse, silence).
|
|
240
|
+
await new Promise((resolve, reject) => {
|
|
241
|
+
httpServer.once("error", (error) => {
|
|
242
|
+
reject(new Error(`Failed to bind HTTP server on port ${httpPort}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
|
|
243
|
+
});
|
|
244
|
+
if (httpServer.listening) {
|
|
245
|
+
resolve();
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
httpServer.once("listening", () => resolve());
|
|
249
|
+
}
|
|
250
|
+
});
|
|
226
251
|
const listeningPort = httpPort === 0
|
|
227
252
|
? (httpServer.address()?.port ?? httpPort)
|
|
228
253
|
: httpPort;
|
|
@@ -877,7 +902,21 @@ async function main() {
|
|
|
877
902
|
void shutdown("stdin-close");
|
|
878
903
|
});
|
|
879
904
|
}
|
|
880
|
-
|
|
905
|
+
// Only open the HTTP listener when something will actually be served on
|
|
906
|
+
// it. A stdio-only run previously bound port 3000 unconditionally,
|
|
907
|
+
// colliding with common dev servers while serving nothing useful
|
|
908
|
+
// (see upstream issue #66).
|
|
909
|
+
if (features.enableMcp || features.enableEditor) {
|
|
910
|
+
await app.startHttpServer(config.httpPort, config, features);
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
app.log.debug("HTTP server not started: no HTTP surface enabled (stdio-only run). Use --transport http or --editor to serve HTTP.");
|
|
914
|
+
const httpPortExplicit = cliArgs.includes("--http-port") ||
|
|
915
|
+
Boolean(process.env.DRAWIO_MCP_HTTP_PORT?.length);
|
|
916
|
+
if (httpPortExplicit) {
|
|
917
|
+
app.log.log("warning", `--http-port was set but no HTTP surface is enabled; port ${config.httpPort} stays free. Use --transport http or --editor to serve HTTP.`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
881
920
|
app.log.debug(`Draw.io MCP Server running on ${config.transports}`);
|
|
882
921
|
}
|
|
883
922
|
const isMainModule = process.argv[1]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, basename, join } from "node:path";
|
|
4
|
+
import { createPatch } from "diff";
|
|
5
|
+
export async function atomicWrite(target, contents) {
|
|
6
|
+
const dir = dirname(target);
|
|
7
|
+
const tmp = join(dir, `.${basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
8
|
+
await fs.writeFile(tmp, contents, "utf8");
|
|
9
|
+
await fs.rename(tmp, target);
|
|
10
|
+
}
|
|
11
|
+
export async function ensureBackup(target) {
|
|
12
|
+
if (!existsSync(target))
|
|
13
|
+
return null;
|
|
14
|
+
const bak = `${target}.bak-${Date.now()}`;
|
|
15
|
+
const contents = await fs.readFile(target, "utf8");
|
|
16
|
+
await fs.writeFile(bak, contents, "utf8");
|
|
17
|
+
return bak;
|
|
18
|
+
}
|
|
19
|
+
export function unifiedDiff(oldText, newText, label) {
|
|
20
|
+
return createPatch(label, oldText, newText, "", "", { context: 3 });
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, readdirSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { atomicWrite, ensureBackup, unifiedDiff } from "./config-io.js";
|
|
6
|
+
describe("config-io", () => {
|
|
7
|
+
let dir;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), "drawio-install-"));
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
it("atomicWrite writes contents and leaves no tmp file", async () => {
|
|
15
|
+
const target = join(dir, "cfg.json");
|
|
16
|
+
await atomicWrite(target, '{\n "a": 1\n}\n');
|
|
17
|
+
expect(readFileSync(target, "utf8")).toBe('{\n "a": 1\n}\n');
|
|
18
|
+
expect(readdirSync(dir).filter((f) => f.startsWith(".cfg.json."))).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
it("ensureBackup copies once and returns backup path", async () => {
|
|
21
|
+
const target = join(dir, "cfg.json");
|
|
22
|
+
writeFileSync(target, "orig");
|
|
23
|
+
const bak = await ensureBackup(target);
|
|
24
|
+
expect(bak).toMatch(/cfg\.json\.bak-\d+$/);
|
|
25
|
+
expect(readFileSync(bak, "utf8")).toBe("orig");
|
|
26
|
+
});
|
|
27
|
+
it("ensureBackup returns null when file missing", async () => {
|
|
28
|
+
expect(await ensureBackup(join(dir, "nope.json"))).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("unifiedDiff produces a diff header referencing the label", () => {
|
|
31
|
+
const d = unifiedDiff("a\n", "b\n", "cfg.json");
|
|
32
|
+
expect(d).toContain("--- cfg.json");
|
|
33
|
+
expect(d).toContain("+++ cfg.json");
|
|
34
|
+
expect(d).toContain("-a");
|
|
35
|
+
expect(d).toContain("+b");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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 } from "jsonc-parser";
|
|
5
|
+
export const claudeCodeAdapter = {
|
|
6
|
+
id: "claude-code",
|
|
7
|
+
displayName: "Claude Code (user scope)",
|
|
8
|
+
defaultPaths() {
|
|
9
|
+
return [join(homedir(), ".claude.json")];
|
|
10
|
+
},
|
|
11
|
+
async detect() {
|
|
12
|
+
return existsSync(claudeCodeAdapter.defaultPaths()[0])
|
|
13
|
+
? "installed"
|
|
14
|
+
: "absent";
|
|
15
|
+
},
|
|
16
|
+
async read(path) {
|
|
17
|
+
return existsSync(path) ? fs.readFile(path, "utf8") : "";
|
|
18
|
+
},
|
|
19
|
+
merge(source, entry, name, { uninstall }) {
|
|
20
|
+
const text = source.trim() === "" ? "{}\n" : source;
|
|
21
|
+
const value = uninstall
|
|
22
|
+
? undefined
|
|
23
|
+
: {
|
|
24
|
+
type: entry.transport,
|
|
25
|
+
command: entry.command,
|
|
26
|
+
args: entry.args,
|
|
27
|
+
...(Object.keys(entry.env).length ? { env: entry.env } : {}),
|
|
28
|
+
};
|
|
29
|
+
const edits = modify(text, ["mcpServers", name], value, {
|
|
30
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
31
|
+
});
|
|
32
|
+
return applyEdits(text, edits);
|
|
33
|
+
},
|
|
34
|
+
diffLabel() {
|
|
35
|
+
return claudeCodeAdapter.defaultPaths()[0];
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { claudeCodeAdapter } from "./claude-code.js";
|
|
3
|
+
const ENTRY = {
|
|
4
|
+
command: "npx",
|
|
5
|
+
args: ["-y", "drawio-mcp-server", "--editor"],
|
|
6
|
+
env: {},
|
|
7
|
+
transport: "stdio",
|
|
8
|
+
};
|
|
9
|
+
describe("claudeCodeAdapter.merge", () => {
|
|
10
|
+
it("adds mcpServers.drawio to a fresh file", () => {
|
|
11
|
+
const out = claudeCodeAdapter.merge("", ENTRY, "drawio", {
|
|
12
|
+
uninstall: false,
|
|
13
|
+
});
|
|
14
|
+
const parsed = JSON.parse(out);
|
|
15
|
+
expect(parsed.mcpServers.drawio).toEqual({
|
|
16
|
+
type: "stdio",
|
|
17
|
+
command: "npx",
|
|
18
|
+
args: ["-y", "drawio-mcp-server", "--editor"],
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
it("uninstall removes only that key", () => {
|
|
22
|
+
const source = JSON.stringify({
|
|
23
|
+
mcpServers: {
|
|
24
|
+
drawio: { type: "stdio", command: "old" },
|
|
25
|
+
other: { command: "x" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const out = claudeCodeAdapter.merge(source, ENTRY, "drawio", {
|
|
29
|
+
uninstall: true,
|
|
30
|
+
});
|
|
31
|
+
const parsed = JSON.parse(out);
|
|
32
|
+
expect(parsed.mcpServers.drawio).toBeUndefined();
|
|
33
|
+
expect(parsed.mcpServers.other).toEqual({ command: "x" });
|
|
34
|
+
});
|
|
35
|
+
it("preserves unrelated top-level keys", () => {
|
|
36
|
+
const source = JSON.stringify({ projects: { "/tmp": {} }, mcpServers: {} }, null, 2);
|
|
37
|
+
const out = claudeCodeAdapter.merge(source, ENTRY, "drawio", {
|
|
38
|
+
uninstall: false,
|
|
39
|
+
});
|
|
40
|
+
expect(JSON.parse(out).projects).toEqual({ "/tmp": {} });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("claudeCodeAdapter.defaultPaths", () => {
|
|
44
|
+
it("points at ~/.claude.json", () => {
|
|
45
|
+
expect(claudeCodeAdapter.defaultPaths()[0]).toMatch(/\.claude\.json$/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { posix, win32 } from "node:path";
|
|
4
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
5
|
+
export function resolveClaudeDesktopPath(plat, home, env) {
|
|
6
|
+
if (plat === "darwin")
|
|
7
|
+
return posix.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
8
|
+
if (plat === "win32") {
|
|
9
|
+
const appdata = env.APPDATA ?? win32.join(home, "AppData", "Roaming");
|
|
10
|
+
return win32.join(appdata, "Claude", "claude_desktop_config.json");
|
|
11
|
+
}
|
|
12
|
+
return posix.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
13
|
+
}
|
|
14
|
+
export const claudeDesktopAdapter = {
|
|
15
|
+
id: "claude-desktop",
|
|
16
|
+
displayName: "Claude Desktop",
|
|
17
|
+
defaultPaths() {
|
|
18
|
+
return [resolveClaudeDesktopPath(platform(), homedir(), process.env)];
|
|
19
|
+
},
|
|
20
|
+
async detect() {
|
|
21
|
+
return existsSync(claudeDesktopAdapter.defaultPaths()[0])
|
|
22
|
+
? "installed"
|
|
23
|
+
: "absent";
|
|
24
|
+
},
|
|
25
|
+
async read(path) {
|
|
26
|
+
return existsSync(path) ? fs.readFile(path, "utf8") : "";
|
|
27
|
+
},
|
|
28
|
+
merge(source, entry, name, { uninstall }) {
|
|
29
|
+
const text = source.trim() === "" ? "{}\n" : source;
|
|
30
|
+
const value = uninstall
|
|
31
|
+
? undefined
|
|
32
|
+
: {
|
|
33
|
+
command: entry.command,
|
|
34
|
+
args: entry.args,
|
|
35
|
+
...(Object.keys(entry.env).length ? { env: entry.env } : {}),
|
|
36
|
+
};
|
|
37
|
+
const edits = modify(text, ["mcpServers", name], value, {
|
|
38
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
39
|
+
});
|
|
40
|
+
return applyEdits(text, edits);
|
|
41
|
+
},
|
|
42
|
+
diffLabel() {
|
|
43
|
+
return claudeDesktopAdapter.defaultPaths()[0];
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { claudeDesktopAdapter, resolveClaudeDesktopPath, } from "./claude-desktop.js";
|
|
3
|
+
const ENTRY = {
|
|
4
|
+
command: "npx",
|
|
5
|
+
args: ["-y", "drawio-mcp-server", "--editor"],
|
|
6
|
+
env: {},
|
|
7
|
+
transport: "stdio",
|
|
8
|
+
};
|
|
9
|
+
describe("claudeDesktopAdapter.merge", () => {
|
|
10
|
+
it("adds mcpServers.drawio", () => {
|
|
11
|
+
const out = claudeDesktopAdapter.merge("", ENTRY, "drawio", {
|
|
12
|
+
uninstall: false,
|
|
13
|
+
});
|
|
14
|
+
const parsed = JSON.parse(out);
|
|
15
|
+
expect(parsed.mcpServers.drawio).toEqual({
|
|
16
|
+
command: "npx",
|
|
17
|
+
args: ["-y", "drawio-mcp-server", "--editor"],
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
it("preserves comments in jsonc source", () => {
|
|
21
|
+
const source = [
|
|
22
|
+
"{",
|
|
23
|
+
" // pinned by me",
|
|
24
|
+
' "mcpServers": {',
|
|
25
|
+
' "other": { "command": "x" }',
|
|
26
|
+
" }",
|
|
27
|
+
"}",
|
|
28
|
+
].join("\n");
|
|
29
|
+
const out = claudeDesktopAdapter.merge(source, ENTRY, "drawio", {
|
|
30
|
+
uninstall: false,
|
|
31
|
+
});
|
|
32
|
+
expect(out).toContain("// pinned by me");
|
|
33
|
+
expect(out).toContain('"drawio"');
|
|
34
|
+
});
|
|
35
|
+
it("uninstall removes the entry", () => {
|
|
36
|
+
const source = JSON.stringify({
|
|
37
|
+
mcpServers: { drawio: { command: "old" } },
|
|
38
|
+
});
|
|
39
|
+
const out = claudeDesktopAdapter.merge(source, ENTRY, "drawio", {
|
|
40
|
+
uninstall: true,
|
|
41
|
+
});
|
|
42
|
+
expect(JSON.parse(out).mcpServers).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("resolveClaudeDesktopPath", () => {
|
|
46
|
+
it("darwin uses Application Support", () => {
|
|
47
|
+
expect(resolveClaudeDesktopPath("darwin", "/Users/me", {})).toMatch(/Library\/Application Support\/Claude\/claude_desktop_config\.json$/);
|
|
48
|
+
});
|
|
49
|
+
it("win32 uses APPDATA when set", () => {
|
|
50
|
+
expect(resolveClaudeDesktopPath("win32", "C:\\Users\\me", {
|
|
51
|
+
APPDATA: "C:\\Users\\me\\AppData\\Roaming",
|
|
52
|
+
})).toMatch(/AppData\\Roaming\\Claude\\claude_desktop_config\.json$/);
|
|
53
|
+
});
|
|
54
|
+
it("linux uses ~/.config/Claude", () => {
|
|
55
|
+
expect(resolveClaudeDesktopPath("linux", "/home/me", {})).toBe("/home/me/.config/Claude/claude_desktop_config.json");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -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
|
+
}
|