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
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";
|
|
@@ -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
|
-
.
|
|
325
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|