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
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.1.1";
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";
@@ -26,7 +26,19 @@ import { registerTools } from "./tools/index.js";
26
26
  import { createServerWithSchemaStripping } from "./register-tool.js";
27
27
  import { target_document_field } from "./tools/shared.js";
28
28
  import { resolveTlsMaterial } from "./tls/index.js";
29
+ import { handleCompatReport } from "./drawio-compat/log-report.js";
29
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
+ }
30
42
  /**
31
43
  * Display help message and exit
32
44
  */
@@ -222,6 +234,20 @@ async function startHttpServer(createServer, disposeMcpServer, log, httpPort, co
222
234
  }
223
235
  : {}),
224
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
+ });
225
251
  const listeningPort = httpPort === 0
226
252
  ? (httpServer.address()?.port ?? httpPort)
227
253
  : httpPort;
@@ -320,12 +346,12 @@ export function createDrawioMcpApp(options) {
320
346
  };
321
347
  }
322
348
  function listKnownDocuments() {
323
- return [...conns.values()]
324
- .map((entry) => entry.document)
325
- .filter((document) => document !== null);
349
+ return [...conns.values()].flatMap((entry) => [
350
+ ...entry.documents.values(),
351
+ ]);
326
352
  }
327
353
  function findConnectionByDocumentId(documentId) {
328
- return [...conns.values()].find((entry) => entry.document?.id === documentId);
354
+ return [...conns.values()].find((entry) => entry.documents.has(documentId));
329
355
  }
330
356
  function flushSyncWaiters(entry) {
331
357
  for (const resolve of [...entry.sync_waiters]) {
@@ -347,6 +373,24 @@ export function createDrawioMcpApp(options) {
347
373
  }));
348
374
  return true;
349
375
  }
376
+ function broadcastDocumentsChanged() {
377
+ const documents = listKnownDocuments();
378
+ const frame = JSON.stringify({
379
+ __control: "documents-changed",
380
+ documents,
381
+ });
382
+ for (const entry of conns.values()) {
383
+ if (entry.ws.readyState !== WebSocket.OPEN) {
384
+ continue;
385
+ }
386
+ try {
387
+ entry.ws.send(frame);
388
+ }
389
+ catch (error) {
390
+ getLog().debug(`[ws] documents-changed send failed`, error);
391
+ }
392
+ }
393
+ }
350
394
  function requestDocumentSync(entry) {
351
395
  return new Promise((resolve) => {
352
396
  let settled = false;
@@ -392,13 +436,14 @@ export function createDrawioMcpApp(options) {
392
436
  await syncAllDocuments();
393
437
  entry = findConnectionByDocumentId(documentId);
394
438
  }
395
- if (!entry || !entry.document) {
439
+ const document = entry?.documents.get(documentId);
440
+ if (!entry || !document) {
396
441
  throw new Error(`Document with ID ${documentId} was not found`);
397
442
  }
398
443
  return {
399
444
  connection_id: entry.connection_id,
400
445
  target_document: { id: documentId },
401
- document: entry.document,
446
+ document,
402
447
  };
403
448
  }
404
449
  await syncAllDocuments();
@@ -596,7 +641,7 @@ export function createDrawioMcpApp(options) {
596
641
  const entry = {
597
642
  connection_id,
598
643
  ws,
599
- document: null,
644
+ documents: new Map(),
600
645
  updated_at: Date.now(),
601
646
  sync_waiters: new Set(),
602
647
  };
@@ -609,9 +654,26 @@ export function createDrawioMcpApp(options) {
609
654
  const json = JSON.parse(str);
610
655
  getLog().debug(`[ws] received from Extension`, json);
611
656
  if (json?.__control === "document-state") {
612
- entry.document = normalizeDocumentState(json.document);
657
+ const document = normalizeDocumentState(json.document);
658
+ if (document) {
659
+ entry.documents.set(document.id, document);
660
+ }
613
661
  entry.updated_at = Date.now();
614
662
  flushSyncWaiters(entry);
663
+ broadcastDocumentsChanged();
664
+ return;
665
+ }
666
+ if (json?.__control === "document-removed") {
667
+ const removedId = normalizeOptionalString(json.document_id);
668
+ if (removedId) {
669
+ entry.documents.delete(removedId);
670
+ entry.updated_at = Date.now();
671
+ broadcastDocumentsChanged();
672
+ }
673
+ return;
674
+ }
675
+ if (json?.__control === "compat-report") {
676
+ handleCompatReport(json, getLog());
615
677
  return;
616
678
  }
617
679
  emitter.emit(bus_reply_stream, json);
@@ -623,12 +685,14 @@ export function createDrawioMcpApp(options) {
623
685
  ws.on("close", (code) => {
624
686
  flushSyncWaiters(entry);
625
687
  conns.delete(connection_id);
688
+ broadcastDocumentsChanged();
626
689
  getLog().debug(`[ws_handler] WebSocket client ${connection_id} closed with code ${code}`);
627
690
  });
628
691
  ws.on("error", (error) => {
629
692
  getLog().debug(`[ws_handler] WebSocket client error`, error);
630
693
  flushSyncWaiters(entry);
631
694
  conns.delete(connection_id);
695
+ broadcastDocumentsChanged();
632
696
  });
633
697
  });
634
698
  if (!tlsMaterial) {
@@ -652,22 +716,44 @@ export function createDrawioMcpApp(options) {
652
716
  return wsServer;
653
717
  }
654
718
  async function close() {
719
+ getLog().debug(`[close] begin`);
655
720
  emitter.off(bus_request_stream, bus_to_ws_forwarder_listener);
721
+ // ws.close() is graceful and waits for the peer close frame; a dead or
722
+ // slow peer keeps the underlying TCP socket alive, which in turn keeps
723
+ // the ws server's internal http.Server from firing its close callback.
724
+ // terminate() destroys the socket immediately.
656
725
  for (const entry of [...conns.values()]) {
657
726
  try {
658
727
  flushSyncWaiters(entry);
659
- entry.ws.close();
728
+ entry.ws.terminate();
660
729
  }
661
730
  catch {
662
731
  // ignore
663
732
  }
664
733
  }
665
734
  conns.clear();
735
+ getLog().debug(`[close] tracked ws clients terminated`);
666
736
  for (const s of mcpServers) {
667
737
  await s.close();
668
738
  }
669
739
  mcpServers.clear();
740
+ getLog().debug(`[close] mcp servers closed`);
670
741
  if (wsServer) {
742
+ // Also terminate any straggler clients that connected but never
743
+ // completed our handshake (not in `conns`).
744
+ for (const client of wsServer.clients) {
745
+ try {
746
+ client.terminate();
747
+ }
748
+ catch {
749
+ // ignore
750
+ }
751
+ }
752
+ // For port-created WebSocketServer, `_server` is the underlying
753
+ // http.Server; force-close any lingering TCP connections so its
754
+ // close() callback fires promptly.
755
+ const internal = wsServer._server;
756
+ internal?.closeAllConnections?.();
671
757
  await new Promise((resolve, reject) => {
672
758
  wsServer?.close((error) => {
673
759
  if (error) {
@@ -678,6 +764,7 @@ export function createDrawioMcpApp(options) {
678
764
  });
679
765
  });
680
766
  wsServer = undefined;
767
+ getLog().debug(`[close] wsServer closed`);
681
768
  }
682
769
  if (wssHttpsServer) {
683
770
  const hs = wssHttpsServer;
@@ -687,8 +774,13 @@ export function createDrawioMcpApp(options) {
687
774
  // WebSocket upgrade) so the server closes immediately.
688
775
  hs.closeAllConnections?.();
689
776
  await new Promise((resolve) => hs.close(() => resolve()));
777
+ getLog().debug(`[close] wssHttpsServer closed`);
690
778
  }
691
779
  if (httpServer) {
780
+ // Node http.Server.close() waits for keep-alive TCP sockets to drain;
781
+ // MCP HTTP clients keep them open. Force them shut like wssHttpsServer.
782
+ const hs = httpServer;
783
+ hs.closeAllConnections?.();
692
784
  await new Promise((resolve, reject) => {
693
785
  httpServer?.close((error) => {
694
786
  if (error) {
@@ -699,7 +791,9 @@ export function createDrawioMcpApp(options) {
699
791
  });
700
792
  });
701
793
  httpServer = undefined;
794
+ getLog().debug(`[close] httpServer closed`);
702
795
  }
796
+ getLog().debug(`[close] done`);
703
797
  }
704
798
  return {
705
799
  createMcpServer,
@@ -772,10 +866,57 @@ async function main() {
772
866
  app.log.debug("Assets ready!");
773
867
  }
774
868
  await app.startWebSocketServer(config.extensionPort, config.host);
869
+ let shuttingDown = false;
870
+ const shutdown = async (reason) => {
871
+ if (shuttingDown)
872
+ return;
873
+ shuttingDown = true;
874
+ app.log.debug(`[shutdown] triggered by ${reason}`);
875
+ try {
876
+ await app.close();
877
+ }
878
+ catch (error) {
879
+ app.log.log("error", "[shutdown] error while closing app", error);
880
+ }
881
+ process.exit(0);
882
+ };
883
+ process.on("SIGTERM", () => {
884
+ void shutdown("SIGTERM");
885
+ });
886
+ process.on("SIGINT", () => {
887
+ void shutdown("SIGINT");
888
+ });
889
+ process.on("SIGHUP", () => {
890
+ void shutdown("SIGHUP");
891
+ });
775
892
  if (config.transports.indexOf("stdio") > -1) {
776
893
  await app.startStdioTransport();
894
+ // MCP hosts (e.g. Claude Desktop on Windows) signal disconnect by
895
+ // closing the stdio pipe rather than sending a POSIX signal. Without
896
+ // this, the WebSocket server keeps the event loop alive and port
897
+ // 3333 leaks across host restarts.
898
+ process.stdin.once("end", () => {
899
+ void shutdown("stdin-end");
900
+ });
901
+ process.stdin.once("close", () => {
902
+ void shutdown("stdin-close");
903
+ });
904
+ }
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
+ }
777
919
  }
778
- await app.startHttpServer(config.httpPort, config, features);
779
920
  app.log.debug(`Draw.io MCP Server running on ${config.transports}`);
780
921
  }
781
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
+ });