drawio-mcp-server 2.1.1 → 2.2.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.
@@ -0,0 +1,21 @@
1
+ import { rmSync } from "node:fs";
2
+ import { SERVER_COMPAT_MATRIX, versionInWindow, } from "../drawio-compat/matrix.js";
3
+ export { SERVER_COMPAT_MATRIX };
4
+ import { getAssetRoot, getCacheDir } from "./manager.js";
5
+ import { readCachedDrawioVersion } from "./version.js";
6
+ export async function ensureSupportedAssets(config, matrix, log, ports) {
7
+ const cacheDir = getCacheDir(config.assetPath);
8
+ const assetRoot = getAssetRoot(config);
9
+ const cached = await readCachedDrawioVersion(assetRoot);
10
+ if (cached && versionInWindow(cached, matrix)) {
11
+ return { version: cached, refetched: false };
12
+ }
13
+ log.log("warning", `cached drawio v${cached ?? "?"} is outside supported window (>= v${matrix.supportedFloor}); refetching latest`);
14
+ rmSync(assetRoot, { recursive: true, force: true });
15
+ await ports.downloadAndExtract(cacheDir, log);
16
+ const after = await readCachedDrawioVersion(assetRoot);
17
+ if (!after || !versionInWindow(after, matrix)) {
18
+ log.log("error", `drawio latest v${after ?? "?"} is still outside supported window; tools may misbehave`);
19
+ }
20
+ return { version: after, refetched: true };
21
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ensureSupportedAssets } from "./auto-refresh.js";
6
+ import { SERVER_COMPAT_MATRIX } from "../drawio-compat/matrix.js";
7
+ function makeCachedVersion(version) {
8
+ const root = mkdtempSync(join(tmpdir(), "auto-refresh-"));
9
+ const webapp = join(root, "webapp");
10
+ const jsDir = join(webapp, "js");
11
+ mkdirSync(jsDir, { recursive: true });
12
+ writeFileSync(join(jsDir, "app.min.js"), `pre;EditorUi.VERSION="${version}";post`);
13
+ return root;
14
+ }
15
+ const logger = {
16
+ log: jest.fn(),
17
+ };
18
+ describe("ensureSupportedAssets", () => {
19
+ it("returns cached version without refetch when in range", async () => {
20
+ const cacheDir = makeCachedVersion("30.2.6");
21
+ const download = jest.fn(async () => { });
22
+ const result = await ensureSupportedAssets({ assetPath: cacheDir }, SERVER_COMPAT_MATRIX, logger, { downloadAndExtract: download });
23
+ expect(download).not.toHaveBeenCalled();
24
+ expect(result).toEqual({ version: "30.2.6", refetched: false });
25
+ });
26
+ it("wipes cache and re-downloads when cached is out of window", async () => {
27
+ const cacheDir = makeCachedVersion("28.0.0");
28
+ const download = jest.fn(async (target) => {
29
+ const jsDir = join(target, "webapp", "js");
30
+ mkdirSync(jsDir, { recursive: true });
31
+ writeFileSync(join(jsDir, "app.min.js"), `pre;EditorUi.VERSION="30.2.6";post`);
32
+ });
33
+ const result = await ensureSupportedAssets({ assetPath: cacheDir }, SERVER_COMPAT_MATRIX, logger, { downloadAndExtract: download });
34
+ expect(download).toHaveBeenCalled();
35
+ expect(result).toEqual({ version: "30.2.6", refetched: true });
36
+ });
37
+ it("logs error when refetched version is still out of window", async () => {
38
+ const cacheDir = makeCachedVersion("28.0.0");
39
+ const download = jest.fn(async (target) => {
40
+ const jsDir = join(target, "webapp", "js");
41
+ mkdirSync(jsDir, { recursive: true });
42
+ writeFileSync(join(jsDir, "app.min.js"), `pre;EditorUi.VERSION="28.5.0";post`);
43
+ });
44
+ const errorLogs = [];
45
+ const trackingLogger = {
46
+ log: (level, ...args) => {
47
+ if (level === "error")
48
+ errorLogs.push(args);
49
+ },
50
+ };
51
+ await ensureSupportedAssets({ assetPath: cacheDir }, SERVER_COMPAT_MATRIX, trackingLogger, { downloadAndExtract: download });
52
+ expect(errorLogs.length).toBeGreaterThan(0);
53
+ });
54
+ });
@@ -85,11 +85,15 @@ export async function downloadAndExtractAssets(targetDir, log) {
85
85
  }
86
86
  export async function ensureAssets(config, log) {
87
87
  const { getCacheDir, getAssetRoot, assetsExist } = await import("./manager.js");
88
+ const { ensureSupportedAssets, SERVER_COMPAT_MATRIX } = await import("./auto-refresh.js");
88
89
  const cacheDir = getCacheDir(config.assetPath);
89
90
  const assetRoot = getAssetRoot(config);
90
91
  if (!assetsExist(config)) {
91
92
  log.log("info", `Assets not found in ${assetRoot}. Downloading...`);
92
93
  await downloadAndExtractAssets(cacheDir, log);
93
94
  }
95
+ await ensureSupportedAssets(config, SERVER_COMPAT_MATRIX, log, {
96
+ downloadAndExtract: (targetDir) => downloadAndExtractAssets(targetDir, log),
97
+ });
94
98
  return { assetRoot, isLocal: true };
95
99
  }
@@ -0,0 +1,38 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const CHUNK_BYTES = 1024 * 1024;
4
+ const OVERLAP_BYTES = 64;
5
+ const VERSION_RE = /EditorUi\.VERSION\s*=\s*"(\d+\.\d+\.\d+)"/;
6
+ export async function readCachedDrawioVersion(assetRoot) {
7
+ const path = join(assetRoot, "js", "app.min.js");
8
+ if (!existsSync(path))
9
+ return null;
10
+ return new Promise((resolve, reject) => {
11
+ const stream = createReadStream(path, { highWaterMark: CHUNK_BYTES });
12
+ let tail = "";
13
+ let resolved = false;
14
+ const finish = (value) => {
15
+ if (resolved)
16
+ return;
17
+ resolved = true;
18
+ stream.destroy();
19
+ resolve(value);
20
+ };
21
+ stream.on("data", (chunk) => {
22
+ const text = tail + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
23
+ const match = VERSION_RE.exec(text);
24
+ if (match) {
25
+ finish(match[1] ?? null);
26
+ return;
27
+ }
28
+ tail = text.slice(-OVERLAP_BYTES);
29
+ });
30
+ stream.on("end", () => finish(null));
31
+ stream.on("error", (err) => {
32
+ if (resolved)
33
+ return;
34
+ resolved = true;
35
+ reject(err);
36
+ });
37
+ });
38
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readCachedDrawioVersion } from "./version.js";
6
+ function makeFixture(contents) {
7
+ const root = mkdtempSync(join(tmpdir(), "drawio-version-"));
8
+ const jsDir = join(root, "js");
9
+ mkdirSync(jsDir, { recursive: true });
10
+ writeFileSync(join(jsDir, "app.min.js"), contents);
11
+ return root;
12
+ }
13
+ describe("readCachedDrawioVersion", () => {
14
+ it("returns the version parsed from app.min.js", async () => {
15
+ const root = makeFixture(`prefix;EditorUi.VERSION="30.2.6";suffix`);
16
+ await expect(readCachedDrawioVersion(root)).resolves.toBe("30.2.6");
17
+ });
18
+ it("returns null when app.min.js is missing", async () => {
19
+ const root = mkdtempSync(join(tmpdir(), "drawio-version-"));
20
+ await expect(readCachedDrawioVersion(root)).resolves.toBeNull();
21
+ });
22
+ it("returns null when VERSION assignment is absent", async () => {
23
+ const root = makeFixture("no version marker here");
24
+ await expect(readCachedDrawioVersion(root)).resolves.toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,123 @@
1
+ import WebSocket from "ws";
2
+ import { createDrawioMcpApp } from "./index.js";
3
+ import { MemoryLogger } from "./real-environment/logger.js";
4
+ const HOST = "127.0.0.1";
5
+ async function waitForMessage(ws, predicate, timeoutMs = 2000) {
6
+ return await new Promise((resolve, reject) => {
7
+ const timer = setTimeout(() => {
8
+ ws.off("message", onMessage);
9
+ reject(new Error("timeout waiting for matching WebSocket message"));
10
+ }, timeoutMs);
11
+ function onMessage(data) {
12
+ const text = typeof data === "string" ? data : data.toString();
13
+ let json;
14
+ try {
15
+ json = JSON.parse(text);
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ if (predicate(json)) {
21
+ clearTimeout(timer);
22
+ ws.off("message", onMessage);
23
+ resolve(json);
24
+ }
25
+ }
26
+ ws.on("message", onMessage);
27
+ });
28
+ }
29
+ async function openClient(port) {
30
+ const ws = new WebSocket(`ws://${HOST}:${port}`);
31
+ await new Promise((resolve, reject) => {
32
+ ws.once("open", () => resolve());
33
+ ws.once("error", reject);
34
+ });
35
+ // Server sends `sync-document-state` on connect; ignore it here.
36
+ return ws;
37
+ }
38
+ describe("documents-changed broadcast", () => {
39
+ let app;
40
+ let logger;
41
+ let port;
42
+ beforeEach(async () => {
43
+ logger = new MemoryLogger();
44
+ app = createDrawioMcpApp({ log: logger });
45
+ const wsServer = await app.startWebSocketServer(0, HOST);
46
+ port = wsServer.address().port;
47
+ });
48
+ afterEach(async () => {
49
+ await app.close();
50
+ });
51
+ it("emits documents-changed after inbound document-state", async () => {
52
+ const ws = await openClient(port);
53
+ const received = waitForMessage(ws, (json) => json?.__control === "documents-changed");
54
+ ws.send(JSON.stringify({
55
+ __control: "document-state",
56
+ document: {
57
+ id: "doc-a",
58
+ title: "Alpha",
59
+ mode: "device",
60
+ hash: null,
61
+ file_url: null,
62
+ page_count: 1,
63
+ current_page: {
64
+ index: 0,
65
+ id: "p1",
66
+ name: "Page-1",
67
+ is_current: true,
68
+ },
69
+ },
70
+ }));
71
+ const payload = await received;
72
+ expect(payload.documents).toHaveLength(1);
73
+ expect(payload.documents[0].id).toBe("doc-a");
74
+ expect(payload.documents[0].title).toBe("Alpha");
75
+ ws.close();
76
+ });
77
+ it("emits documents-changed after inbound document-removed", async () => {
78
+ const ws = await openClient(port);
79
+ ws.send(JSON.stringify({
80
+ __control: "document-state",
81
+ document: {
82
+ id: "doc-b",
83
+ title: "Beta",
84
+ mode: "device",
85
+ hash: null,
86
+ file_url: null,
87
+ page_count: 1,
88
+ current_page: null,
89
+ },
90
+ }));
91
+ await waitForMessage(ws, (json) => json?.__control === "documents-changed" && json.documents.length === 1);
92
+ const removed = waitForMessage(ws, (json) => json?.__control === "documents-changed" && json.documents.length === 0);
93
+ ws.send(JSON.stringify({
94
+ __control: "document-removed",
95
+ document_id: "doc-b",
96
+ }));
97
+ const payload = await removed;
98
+ expect(payload.documents).toEqual([]);
99
+ ws.close();
100
+ });
101
+ it("emits documents-changed to remaining clients after peer disconnect", async () => {
102
+ const clientA = await openClient(port);
103
+ const clientB = await openClient(port);
104
+ clientA.send(JSON.stringify({
105
+ __control: "document-state",
106
+ document: {
107
+ id: "doc-c",
108
+ title: "Gamma",
109
+ mode: "device",
110
+ hash: null,
111
+ file_url: null,
112
+ page_count: 1,
113
+ current_page: null,
114
+ },
115
+ }));
116
+ await waitForMessage(clientB, (json) => json?.__control === "documents-changed" && json.documents.length === 1);
117
+ const drained = waitForMessage(clientB, (json) => json?.__control === "documents-changed" && json.documents.length === 0);
118
+ clientA.close();
119
+ const payload = await drained;
120
+ expect(payload.documents).toEqual([]);
121
+ clientB.close();
122
+ });
123
+ });
@@ -0,0 +1,21 @@
1
+ export function handleCompatReport(payload, log) {
2
+ const v = payload.drawioVersion ?? "unknown";
3
+ switch (payload.state) {
4
+ case "ok":
5
+ log.log("info", `drawio v${v} is within supported window`);
6
+ return;
7
+ case "below-floor":
8
+ log.log("error", `drawio v${v} predates supported floor v${payload.floor}; ` +
9
+ `version-gated tools will return errors`);
10
+ return;
11
+ case "above-window":
12
+ log.log("warning", `drawio v${v} is newer than the tested window ` +
13
+ `(last tested min: v${payload.detail ?? "?"}); running on newest impl`);
14
+ return;
15
+ case "no-version":
16
+ log.log("warning", `plugin could not detect drawio version (${payload.detail ?? "?"})`);
17
+ return;
18
+ default:
19
+ log.log("warning", `unknown compat state \`${payload.state}\` (drawio v${v})`);
20
+ }
21
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { handleCompatReport } from "./log-report.js";
3
+ function makeLogger() {
4
+ const calls = [];
5
+ return {
6
+ calls,
7
+ logger: {
8
+ log: (lvl, msg) => calls.push([lvl, msg]),
9
+ },
10
+ };
11
+ }
12
+ describe("handleCompatReport", () => {
13
+ it("emits info for ok state", () => {
14
+ const { calls, logger } = makeLogger();
15
+ handleCompatReport({ drawioVersion: "30.2.6", state: "ok", floor: "29.0.0" }, logger);
16
+ expect(calls.some(([lvl]) => lvl === "info")).toBe(true);
17
+ });
18
+ it("emits error for below-floor state", () => {
19
+ const { calls, logger } = makeLogger();
20
+ handleCompatReport({ drawioVersion: "28.0.0", state: "below-floor", floor: "29.0.0" }, logger);
21
+ expect(calls.some(([lvl]) => lvl === "error")).toBe(true);
22
+ });
23
+ it("emits warning for above-window state", () => {
24
+ const { calls, logger } = makeLogger();
25
+ handleCompatReport({
26
+ drawioVersion: "31.0.0",
27
+ state: "above-window",
28
+ floor: "29.0.0",
29
+ detail: "30.0.0",
30
+ }, logger);
31
+ expect(calls.some(([lvl]) => lvl === "warning")).toBe(true);
32
+ });
33
+ it("emits warning for no-version state", () => {
34
+ const { calls, logger } = makeLogger();
35
+ handleCompatReport({
36
+ drawioVersion: null,
37
+ state: "no-version",
38
+ floor: "29.0.0",
39
+ detail: "missing",
40
+ }, logger);
41
+ expect(calls.some(([lvl]) => lvl === "warning")).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,14 @@
1
+ import { isInRange, parseVersion, } from "../vendored/compat/index.js";
2
+ export const SERVER_COMPAT_MATRIX = {
3
+ supportedFloor: "29.0.0",
4
+ supportedRanges: [
5
+ { min: "29.0.0", maxExclusive: "30.0.0" },
6
+ { min: "30.0.0", maxExclusive: null },
7
+ ],
8
+ };
9
+ export function versionInWindow(version, matrix) {
10
+ const parsed = parseVersion(version);
11
+ if (!parsed)
12
+ return false;
13
+ return matrix.supportedRanges.some((r) => isInRange(parsed, r));
14
+ }
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.2.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,6 +26,7 @@ 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();
30
31
  /**
31
32
  * Display help message and exit
@@ -320,12 +321,12 @@ export function createDrawioMcpApp(options) {
320
321
  };
321
322
  }
322
323
  function listKnownDocuments() {
323
- return [...conns.values()]
324
- .map((entry) => entry.document)
325
- .filter((document) => document !== null);
324
+ return [...conns.values()].flatMap((entry) => [
325
+ ...entry.documents.values(),
326
+ ]);
326
327
  }
327
328
  function findConnectionByDocumentId(documentId) {
328
- return [...conns.values()].find((entry) => entry.document?.id === documentId);
329
+ return [...conns.values()].find((entry) => entry.documents.has(documentId));
329
330
  }
330
331
  function flushSyncWaiters(entry) {
331
332
  for (const resolve of [...entry.sync_waiters]) {
@@ -347,6 +348,24 @@ export function createDrawioMcpApp(options) {
347
348
  }));
348
349
  return true;
349
350
  }
351
+ function broadcastDocumentsChanged() {
352
+ const documents = listKnownDocuments();
353
+ const frame = JSON.stringify({
354
+ __control: "documents-changed",
355
+ documents,
356
+ });
357
+ for (const entry of conns.values()) {
358
+ if (entry.ws.readyState !== WebSocket.OPEN) {
359
+ continue;
360
+ }
361
+ try {
362
+ entry.ws.send(frame);
363
+ }
364
+ catch (error) {
365
+ getLog().debug(`[ws] documents-changed send failed`, error);
366
+ }
367
+ }
368
+ }
350
369
  function requestDocumentSync(entry) {
351
370
  return new Promise((resolve) => {
352
371
  let settled = false;
@@ -392,13 +411,14 @@ export function createDrawioMcpApp(options) {
392
411
  await syncAllDocuments();
393
412
  entry = findConnectionByDocumentId(documentId);
394
413
  }
395
- if (!entry || !entry.document) {
414
+ const document = entry?.documents.get(documentId);
415
+ if (!entry || !document) {
396
416
  throw new Error(`Document with ID ${documentId} was not found`);
397
417
  }
398
418
  return {
399
419
  connection_id: entry.connection_id,
400
420
  target_document: { id: documentId },
401
- document: entry.document,
421
+ document,
402
422
  };
403
423
  }
404
424
  await syncAllDocuments();
@@ -596,7 +616,7 @@ export function createDrawioMcpApp(options) {
596
616
  const entry = {
597
617
  connection_id,
598
618
  ws,
599
- document: null,
619
+ documents: new Map(),
600
620
  updated_at: Date.now(),
601
621
  sync_waiters: new Set(),
602
622
  };
@@ -609,9 +629,26 @@ export function createDrawioMcpApp(options) {
609
629
  const json = JSON.parse(str);
610
630
  getLog().debug(`[ws] received from Extension`, json);
611
631
  if (json?.__control === "document-state") {
612
- entry.document = normalizeDocumentState(json.document);
632
+ const document = normalizeDocumentState(json.document);
633
+ if (document) {
634
+ entry.documents.set(document.id, document);
635
+ }
613
636
  entry.updated_at = Date.now();
614
637
  flushSyncWaiters(entry);
638
+ broadcastDocumentsChanged();
639
+ return;
640
+ }
641
+ if (json?.__control === "document-removed") {
642
+ const removedId = normalizeOptionalString(json.document_id);
643
+ if (removedId) {
644
+ entry.documents.delete(removedId);
645
+ entry.updated_at = Date.now();
646
+ broadcastDocumentsChanged();
647
+ }
648
+ return;
649
+ }
650
+ if (json?.__control === "compat-report") {
651
+ handleCompatReport(json, getLog());
615
652
  return;
616
653
  }
617
654
  emitter.emit(bus_reply_stream, json);
@@ -623,12 +660,14 @@ export function createDrawioMcpApp(options) {
623
660
  ws.on("close", (code) => {
624
661
  flushSyncWaiters(entry);
625
662
  conns.delete(connection_id);
663
+ broadcastDocumentsChanged();
626
664
  getLog().debug(`[ws_handler] WebSocket client ${connection_id} closed with code ${code}`);
627
665
  });
628
666
  ws.on("error", (error) => {
629
667
  getLog().debug(`[ws_handler] WebSocket client error`, error);
630
668
  flushSyncWaiters(entry);
631
669
  conns.delete(connection_id);
670
+ broadcastDocumentsChanged();
632
671
  });
633
672
  });
634
673
  if (!tlsMaterial) {
@@ -652,22 +691,44 @@ export function createDrawioMcpApp(options) {
652
691
  return wsServer;
653
692
  }
654
693
  async function close() {
694
+ getLog().debug(`[close] begin`);
655
695
  emitter.off(bus_request_stream, bus_to_ws_forwarder_listener);
696
+ // ws.close() is graceful and waits for the peer close frame; a dead or
697
+ // slow peer keeps the underlying TCP socket alive, which in turn keeps
698
+ // the ws server's internal http.Server from firing its close callback.
699
+ // terminate() destroys the socket immediately.
656
700
  for (const entry of [...conns.values()]) {
657
701
  try {
658
702
  flushSyncWaiters(entry);
659
- entry.ws.close();
703
+ entry.ws.terminate();
660
704
  }
661
705
  catch {
662
706
  // ignore
663
707
  }
664
708
  }
665
709
  conns.clear();
710
+ getLog().debug(`[close] tracked ws clients terminated`);
666
711
  for (const s of mcpServers) {
667
712
  await s.close();
668
713
  }
669
714
  mcpServers.clear();
715
+ getLog().debug(`[close] mcp servers closed`);
670
716
  if (wsServer) {
717
+ // Also terminate any straggler clients that connected but never
718
+ // completed our handshake (not in `conns`).
719
+ for (const client of wsServer.clients) {
720
+ try {
721
+ client.terminate();
722
+ }
723
+ catch {
724
+ // ignore
725
+ }
726
+ }
727
+ // For port-created WebSocketServer, `_server` is the underlying
728
+ // http.Server; force-close any lingering TCP connections so its
729
+ // close() callback fires promptly.
730
+ const internal = wsServer._server;
731
+ internal?.closeAllConnections?.();
671
732
  await new Promise((resolve, reject) => {
672
733
  wsServer?.close((error) => {
673
734
  if (error) {
@@ -678,6 +739,7 @@ export function createDrawioMcpApp(options) {
678
739
  });
679
740
  });
680
741
  wsServer = undefined;
742
+ getLog().debug(`[close] wsServer closed`);
681
743
  }
682
744
  if (wssHttpsServer) {
683
745
  const hs = wssHttpsServer;
@@ -687,8 +749,13 @@ export function createDrawioMcpApp(options) {
687
749
  // WebSocket upgrade) so the server closes immediately.
688
750
  hs.closeAllConnections?.();
689
751
  await new Promise((resolve) => hs.close(() => resolve()));
752
+ getLog().debug(`[close] wssHttpsServer closed`);
690
753
  }
691
754
  if (httpServer) {
755
+ // Node http.Server.close() waits for keep-alive TCP sockets to drain;
756
+ // MCP HTTP clients keep them open. Force them shut like wssHttpsServer.
757
+ const hs = httpServer;
758
+ hs.closeAllConnections?.();
692
759
  await new Promise((resolve, reject) => {
693
760
  httpServer?.close((error) => {
694
761
  if (error) {
@@ -699,7 +766,9 @@ export function createDrawioMcpApp(options) {
699
766
  });
700
767
  });
701
768
  httpServer = undefined;
769
+ getLog().debug(`[close] httpServer closed`);
702
770
  }
771
+ getLog().debug(`[close] done`);
703
772
  }
704
773
  return {
705
774
  createMcpServer,
@@ -772,8 +841,41 @@ async function main() {
772
841
  app.log.debug("Assets ready!");
773
842
  }
774
843
  await app.startWebSocketServer(config.extensionPort, config.host);
844
+ let shuttingDown = false;
845
+ const shutdown = async (reason) => {
846
+ if (shuttingDown)
847
+ return;
848
+ shuttingDown = true;
849
+ app.log.debug(`[shutdown] triggered by ${reason}`);
850
+ try {
851
+ await app.close();
852
+ }
853
+ catch (error) {
854
+ app.log.log("error", "[shutdown] error while closing app", error);
855
+ }
856
+ process.exit(0);
857
+ };
858
+ process.on("SIGTERM", () => {
859
+ void shutdown("SIGTERM");
860
+ });
861
+ process.on("SIGINT", () => {
862
+ void shutdown("SIGINT");
863
+ });
864
+ process.on("SIGHUP", () => {
865
+ void shutdown("SIGHUP");
866
+ });
775
867
  if (config.transports.indexOf("stdio") > -1) {
776
868
  await app.startStdioTransport();
869
+ // MCP hosts (e.g. Claude Desktop on Windows) signal disconnect by
870
+ // closing the stdio pipe rather than sending a POSIX signal. Without
871
+ // this, the WebSocket server keeps the event loop alive and port
872
+ // 3333 leaks across host restarts.
873
+ process.stdin.once("end", () => {
874
+ void shutdown("stdin-end");
875
+ });
876
+ process.stdin.once("close", () => {
877
+ void shutdown("stdin-close");
878
+ });
777
879
  }
778
880
  await app.startHttpServer(config.httpPort, config, features);
779
881
  app.log.debug(`Draw.io MCP Server running on ${config.transports}`);
@@ -224,94 +224,6 @@ var DrawMcp = (() => {
224
224
  );
225
225
  }
226
226
  }
227
- function import_mermaid(ui2, options) {
228
- const opts = options;
229
- const mermaidSource = opts.mermaid_source;
230
- const mode = opts.mode ?? "native";
231
- const insertMode = opts.insert_mode ?? "add";
232
- if (!mermaidSource || typeof mermaidSource !== "string") {
233
- return Promise.resolve({
234
- success: false,
235
- message: "mermaid_source must be a non-empty string"
236
- });
237
- }
238
- if (typeof ui2?.parseMermaidDiagram !== "function") {
239
- return Promise.resolve({
240
- success: false,
241
- message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
242
- });
243
- }
244
- const enableParser = mode === "native";
245
- return new Promise((resolve) => {
246
- let settled = false;
247
- const settle = (result) => {
248
- if (settled) {
249
- return;
250
- }
251
- settled = true;
252
- resolve(result);
253
- };
254
- try {
255
- ui2.parseMermaidDiagram(
256
- mermaidSource,
257
- void 0,
258
- (xml) => {
259
- if (!xml || typeof xml !== "string") {
260
- settle({
261
- success: false,
262
- message: "Mermaid parser returned empty XML"
263
- });
264
- return;
265
- }
266
- try {
267
- const importResult = import_diagram(ui2, {
268
- data: xml,
269
- format: "xml",
270
- mode: insertMode
271
- });
272
- if (!importResult.success) {
273
- settle({
274
- success: false,
275
- message: `Mermaid converted, but inserting into the diagram failed: ${importResult.message}`
276
- });
277
- return;
278
- }
279
- settle({
280
- success: true,
281
- mode,
282
- message: importResult.message,
283
- cells: importResult.cells,
284
- xml
285
- });
286
- } catch (err) {
287
- settle({
288
- success: false,
289
- message: `Insert after Mermaid conversion failed: ${err instanceof Error ? err.message : String(err)}`
290
- });
291
- }
292
- },
293
- (err) => {
294
- settle({
295
- success: false,
296
- message: `Mermaid render failed: ${err?.message ?? String(err)}`
297
- });
298
- },
299
- (err) => {
300
- settle({
301
- success: false,
302
- message: `Mermaid parse error: ${err?.message ?? String(err)}`
303
- });
304
- },
305
- enableParser
306
- );
307
- } catch (err) {
308
- settle({
309
- success: false,
310
- message: `parseMermaidDiagram threw: ${err instanceof Error ? err.message : String(err)}`
311
- });
312
- }
313
- });
314
- }
315
227
  function transform_NamedNodeMap_to_record(attributes) {
316
228
  const tstr = Object.prototype.toString.call(attributes);
317
229
  if (tstr !== "[object NamedNodeMap]") {
@@ -2304,6 +2216,271 @@ var DrawMcp = (() => {
2304
2216
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "default";
2305
2217
  }
2306
2218
 
2219
+ // ../drawio-mcp-compat/dist/index.js
2220
+ var SEMVER_HEAD = /^(\d+)\.(\d+)\.(\d+)/;
2221
+ function parseVersion(raw) {
2222
+ const m = SEMVER_HEAD.exec(raw);
2223
+ if (!m)
2224
+ return null;
2225
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
2226
+ }
2227
+ function compareVersion(a, b) {
2228
+ for (let i = 0; i < 3; i++) {
2229
+ if (a[i] < b[i])
2230
+ return -1;
2231
+ if (a[i] > b[i])
2232
+ return 1;
2233
+ }
2234
+ return 0;
2235
+ }
2236
+ function isBelowFloor(v, floor) {
2237
+ const parsed = parseVersion(floor);
2238
+ if (!parsed)
2239
+ return false;
2240
+ return compareVersion(v, parsed) < 0;
2241
+ }
2242
+ function isInRange(v, r) {
2243
+ const min = parseVersion(r.min);
2244
+ if (!min)
2245
+ return false;
2246
+ if (compareVersion(v, min) < 0)
2247
+ return false;
2248
+ if (r.maxExclusive === null)
2249
+ return true;
2250
+ const max = parseVersion(r.maxExclusive);
2251
+ if (!max)
2252
+ return true;
2253
+ return compareVersion(v, max) < 0;
2254
+ }
2255
+
2256
+ // src/drawio-compat/detect.ts
2257
+ function detectDrawioVersion(ui2) {
2258
+ const raw = globalThis.EditorUi?.VERSION ?? ui2?.constructor?.VERSION;
2259
+ if (typeof raw !== "string") {
2260
+ return { ok: false, reason: "missing", raw: null };
2261
+ }
2262
+ const semver = parseVersion(raw);
2263
+ if (!semver) return { ok: false, reason: "unparseable", raw };
2264
+ return { ok: true, raw, semver };
2265
+ }
2266
+ var cached = null;
2267
+ function getDetectedDrawioVersion(ui2) {
2268
+ if (cached !== null) return cached;
2269
+ const result = detectDrawioVersion(ui2);
2270
+ if (result.ok) cached = result;
2271
+ return result;
2272
+ }
2273
+
2274
+ // src/drawio-compat/dispatch.ts
2275
+ function dispatchTool(toolName, detected, matrix) {
2276
+ const entries = matrix.versionedTools[toolName];
2277
+ if (!entries || entries.length === 0) return null;
2278
+ if (!detected.ok) {
2279
+ return { kind: "no-version", reason: detected.reason };
2280
+ }
2281
+ if (isBelowFloor(detected.semver, matrix.supportedFloor)) {
2282
+ return {
2283
+ kind: "below-floor",
2284
+ floor: matrix.supportedFloor,
2285
+ detected: detected.raw
2286
+ };
2287
+ }
2288
+ for (const entry of entries) {
2289
+ if (isInRange(detected.semver, entry.range)) {
2290
+ return { kind: "matched", impl: entry.impl };
2291
+ }
2292
+ }
2293
+ const sorted = [...entries].sort((a, b) => {
2294
+ const am = parseVersion(a.range.min);
2295
+ const bm = parseVersion(b.range.min);
2296
+ return compareVersion(am, bm);
2297
+ });
2298
+ return {
2299
+ kind: "above-window",
2300
+ lastRangeMin: sorted.at(-1).range.min,
2301
+ detected: detected.raw
2302
+ };
2303
+ }
2304
+
2305
+ // src/tools/import-mermaid/shared.ts
2306
+ function validateOptions(options) {
2307
+ const opts = options;
2308
+ const source = opts.mermaid_source;
2309
+ if (!source || typeof source !== "string") {
2310
+ return {
2311
+ success: false,
2312
+ message: "mermaid_source must be a non-empty string"
2313
+ };
2314
+ }
2315
+ return {
2316
+ source,
2317
+ mode: opts.mode ?? "native",
2318
+ insertMode: opts.insert_mode ?? "add"
2319
+ };
2320
+ }
2321
+ function runInsertFlow(ui2, mode, insertMode, resolve) {
2322
+ let settled = false;
2323
+ const settle = (result) => {
2324
+ if (settled) return;
2325
+ settled = true;
2326
+ resolve(result);
2327
+ };
2328
+ const onXml = (xml) => {
2329
+ if (!xml || typeof xml !== "string") {
2330
+ settle({ success: false, message: "Mermaid parser returned empty XML" });
2331
+ return;
2332
+ }
2333
+ try {
2334
+ const importResult = import_diagram(ui2, {
2335
+ data: xml,
2336
+ format: "xml",
2337
+ mode: insertMode
2338
+ });
2339
+ if (!importResult.success) {
2340
+ settle({
2341
+ success: false,
2342
+ message: `Mermaid converted, but inserting into the diagram failed: ${importResult.message}`
2343
+ });
2344
+ return;
2345
+ }
2346
+ settle({
2347
+ success: true,
2348
+ mode,
2349
+ message: importResult.message,
2350
+ cells: importResult.cells,
2351
+ xml
2352
+ });
2353
+ } catch (err) {
2354
+ settle({
2355
+ success: false,
2356
+ message: `Insert after Mermaid conversion failed: ${err instanceof Error ? err.message : String(err)}`
2357
+ });
2358
+ }
2359
+ };
2360
+ const onError = (err) => {
2361
+ settle({
2362
+ success: false,
2363
+ message: `Mermaid render failed: ${err?.message ?? String(err)}`
2364
+ });
2365
+ };
2366
+ return { onXml, onError };
2367
+ }
2368
+
2369
+ // src/tools/import-mermaid/v29.ts
2370
+ function import_mermaid(ui2, options) {
2371
+ const validated = validateOptions(options);
2372
+ if ("success" in validated) return Promise.resolve(validated);
2373
+ const { source, mode, insertMode } = validated;
2374
+ if (typeof ui2?.parseMermaidDiagram !== "function") {
2375
+ return Promise.resolve({
2376
+ success: false,
2377
+ message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
2378
+ });
2379
+ }
2380
+ const enableParser = mode === "native";
2381
+ return new Promise((resolve) => {
2382
+ const { onXml, onError } = runInsertFlow(ui2, mode, insertMode, resolve);
2383
+ try {
2384
+ ui2.parseMermaidDiagram(
2385
+ source,
2386
+ void 0,
2387
+ onXml,
2388
+ onError,
2389
+ onError,
2390
+ enableParser
2391
+ );
2392
+ } catch (err) {
2393
+ resolve({
2394
+ success: false,
2395
+ message: `parseMermaidDiagram threw: ${err instanceof Error ? err.message : String(err)}`
2396
+ });
2397
+ }
2398
+ });
2399
+ }
2400
+
2401
+ // src/tools/import-mermaid/v30.ts
2402
+ function import_mermaid2(ui2, options) {
2403
+ const validated = validateOptions(options);
2404
+ if ("success" in validated) return Promise.resolve(validated);
2405
+ const { source, mode, insertMode } = validated;
2406
+ if (typeof ui2?.parseMermaidDiagram !== "function") {
2407
+ return Promise.resolve({
2408
+ success: false,
2409
+ message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
2410
+ });
2411
+ }
2412
+ return new Promise((resolve) => {
2413
+ const { onXml, onError } = runInsertFlow(ui2, mode, insertMode, resolve);
2414
+ try {
2415
+ if (mode === "embed" && typeof ui2.parseMermaidImage === "function") {
2416
+ ui2.parseMermaidImage(source, onXml, onError);
2417
+ } else {
2418
+ ui2.parseMermaidDiagram(source, void 0, onXml, onError, onError);
2419
+ }
2420
+ } catch (err) {
2421
+ resolve({
2422
+ success: false,
2423
+ message: `Mermaid API threw: ${err instanceof Error ? err.message : String(err)}`
2424
+ });
2425
+ }
2426
+ });
2427
+ }
2428
+
2429
+ // src/drawio-compat/matrix.ts
2430
+ var COMPAT_MATRIX = {
2431
+ supportedFloor: "29.0.0",
2432
+ versionedTools: {
2433
+ "import-mermaid": [
2434
+ {
2435
+ range: { min: "29.0.0", maxExclusive: "30.0.0" },
2436
+ impl: import_mermaid
2437
+ },
2438
+ {
2439
+ range: { min: "30.0.0", maxExclusive: null },
2440
+ impl: import_mermaid2
2441
+ }
2442
+ ]
2443
+ }
2444
+ };
2445
+
2446
+ // src/tools/import-mermaid/index.ts
2447
+ var TOOL_NAME = "import-mermaid";
2448
+ function import_mermaid3(ui2, options) {
2449
+ const detected = getDetectedDrawioVersion(ui2);
2450
+ const outcome = dispatchTool(TOOL_NAME, detected, COMPAT_MATRIX);
2451
+ if (outcome === null) {
2452
+ return Promise.resolve({
2453
+ success: false,
2454
+ message: `import-mermaid has no matrix entry; refusing to guess.`
2455
+ });
2456
+ }
2457
+ switch (outcome.kind) {
2458
+ case "matched":
2459
+ return outcome.impl(ui2, options);
2460
+ case "above-window": {
2461
+ const entries = COMPAT_MATRIX.versionedTools[TOOL_NAME] ?? [];
2462
+ const fallback = entries.at(-1);
2463
+ if (!fallback) {
2464
+ return Promise.resolve({
2465
+ success: false,
2466
+ message: `no impl available for drawio v${outcome.detected}`
2467
+ });
2468
+ }
2469
+ return fallback.impl(ui2, options);
2470
+ }
2471
+ case "below-floor":
2472
+ return Promise.resolve({
2473
+ success: false,
2474
+ message: `drawio v${outcome.detected} predates supported floor v${outcome.floor}. Upgrade drawio.`
2475
+ });
2476
+ case "no-version":
2477
+ return Promise.resolve({
2478
+ success: false,
2479
+ message: `cannot detect drawio version (${outcome.reason}); pin a supported drawio build.`
2480
+ });
2481
+ }
2482
+ }
2483
+
2307
2484
  // src/tool-registry.ts
2308
2485
  var VISIBLE_PAGE_EXECUTION = {
2309
2486
  mode: "visible-page"
@@ -2583,7 +2760,7 @@ var DrawMcp = (() => {
2583
2760
  page_tool({
2584
2761
  name: "import-mermaid",
2585
2762
  params: /* @__PURE__ */ new Set(["mermaid_source", "mode", "insert_mode", "target_page"]),
2586
- handler: import_mermaid,
2763
+ handler: import_mermaid3,
2587
2764
  pageExecution: VISIBLE_PAGE_MUTATION_EXECUTION,
2588
2765
  skip: skip_page_execution_for_new_page_mermaid
2589
2766
  }),
@@ -2621,6 +2798,61 @@ var DrawMcp = (() => {
2621
2798
  })
2622
2799
  );
2623
2800
 
2801
+ // src/drawio-compat/report.ts
2802
+ function computeCompatReport(matrix = COMPAT_MATRIX) {
2803
+ const detected = getDetectedDrawioVersion();
2804
+ if (!detected.ok) {
2805
+ return {
2806
+ drawioVersion: detected.raw,
2807
+ state: "no-version",
2808
+ floor: matrix.supportedFloor,
2809
+ detail: detected.reason
2810
+ };
2811
+ }
2812
+ if (isBelowFloor(detected.semver, matrix.supportedFloor)) {
2813
+ return {
2814
+ drawioVersion: detected.raw,
2815
+ state: "below-floor",
2816
+ floor: matrix.supportedFloor
2817
+ };
2818
+ }
2819
+ const anyBounded = Object.values(matrix.versionedTools).some(
2820
+ (entries) => entries.at(-1)?.range.maxExclusive !== null
2821
+ );
2822
+ if (anyBounded) {
2823
+ const boundedFor = Object.entries(matrix.versionedTools).filter(
2824
+ ([, entries]) => entries.at(-1)?.range.maxExclusive !== null
2825
+ );
2826
+ for (const [, entries] of boundedFor) {
2827
+ const newest = entries.at(-1);
2828
+ const max = parseVersion(newest.range.maxExclusive);
2829
+ if (max) {
2830
+ if (detected.semver[0] > max[0] || detected.semver[0] === max[0] && detected.semver[1] > max[1] || detected.semver[0] === max[0] && detected.semver[1] === max[1] && detected.semver[2] >= max[2]) {
2831
+ return {
2832
+ drawioVersion: detected.raw,
2833
+ state: "above-window",
2834
+ floor: matrix.supportedFloor,
2835
+ detail: newest.range.min
2836
+ };
2837
+ }
2838
+ }
2839
+ }
2840
+ }
2841
+ return {
2842
+ drawioVersion: detected.raw,
2843
+ state: "ok",
2844
+ floor: matrix.supportedFloor
2845
+ };
2846
+ }
2847
+ function sendCompatReport(send, log = console.log.bind(console)) {
2848
+ const report = computeCompatReport();
2849
+ send({ __control: "compat-report", ...report });
2850
+ log(
2851
+ `[drawio-mcp] drawio version: ${report.drawioVersion ?? "unknown"} \u2014 compat state: ${report.state} (floor: ${report.floor})`
2852
+ );
2853
+ return report;
2854
+ }
2855
+
2624
2856
  // src/bootstrap.ts
2625
2857
  var SHAPE_EXTRACTION_MAX_ATTEMPTS = 10;
2626
2858
  var SHAPE_EXTRACTION_INTERVAL_MS = 1e3;
@@ -2822,6 +3054,7 @@ var DrawMcp = (() => {
2822
3054
  cleanups.push(() => clearInterval(intervalId));
2823
3055
  }
2824
3056
  }
3057
+ sendCompatReport(transport2.send.bind(transport2));
2825
3058
  return {
2826
3059
  syncDocumentState,
2827
3060
  dispose: () => {
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, expect, it } from "@jest/globals";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:net";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import WebSocket from "ws";
7
+ async function getFreePort() {
8
+ return new Promise((resolve, reject) => {
9
+ const srv = createServer();
10
+ srv.once("error", reject);
11
+ srv.listen(0, "127.0.0.1", () => {
12
+ const addr = srv.address();
13
+ if (addr && typeof addr === "object") {
14
+ const port = addr.port;
15
+ srv.close(() => resolve(port));
16
+ }
17
+ else {
18
+ srv.close(() => reject(new Error("could not resolve free port")));
19
+ }
20
+ });
21
+ });
22
+ }
23
+ async function isPortFree(port, host = "127.0.0.1") {
24
+ return new Promise((resolve) => {
25
+ const srv = createServer();
26
+ srv.once("error", () => resolve(false));
27
+ srv.listen({ port, host }, () => {
28
+ srv.close(() => resolve(true));
29
+ });
30
+ });
31
+ }
32
+ async function waitForPortHeld(port, host, timeoutMs) {
33
+ const deadline = Date.now() + timeoutMs;
34
+ while (Date.now() < deadline) {
35
+ const free = await isPortFree(port, host);
36
+ if (!free)
37
+ return true;
38
+ await new Promise((r) => setTimeout(r, 50));
39
+ }
40
+ return false;
41
+ }
42
+ async function waitForExit(proc, timeoutMs) {
43
+ return new Promise((resolve) => {
44
+ const timer = setTimeout(() => resolve(null), timeoutMs);
45
+ proc.once("exit", (code) => {
46
+ clearTimeout(timer);
47
+ resolve(code ?? 0);
48
+ });
49
+ });
50
+ }
51
+ const here = dirname(fileURLToPath(import.meta.url));
52
+ const SERVER_BIN = join(here, "index.js");
53
+ const HOST = "127.0.0.1";
54
+ function spawnServer(extensionPort, httpPort, transport = "stdio") {
55
+ return spawn(process.execPath, [
56
+ SERVER_BIN,
57
+ "--transport",
58
+ transport,
59
+ "--extension-port",
60
+ String(extensionPort),
61
+ "--http-port",
62
+ String(httpPort),
63
+ "--host",
64
+ HOST,
65
+ ], { stdio: ["pipe", "pipe", "pipe"] });
66
+ }
67
+ describe("graceful shutdown releases WebSocket port", () => {
68
+ let proc;
69
+ afterEach(async () => {
70
+ if (proc && !proc.killed) {
71
+ proc.kill("SIGKILL");
72
+ await new Promise((r) => setTimeout(r, 100));
73
+ }
74
+ proc = undefined;
75
+ });
76
+ it("releases extension port after SIGTERM", async () => {
77
+ const extensionPort = await getFreePort();
78
+ const httpPort = await getFreePort();
79
+ proc = spawnServer(extensionPort, httpPort);
80
+ const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
81
+ expect(portTaken).toBe(true);
82
+ proc.kill("SIGTERM");
83
+ const exitCode = await waitForExit(proc, 5000);
84
+ expect(exitCode).not.toBeNull();
85
+ const free = await isPortFree(extensionPort, HOST);
86
+ expect(free).toBe(true);
87
+ }, 20000);
88
+ it("releases extension port after SIGINT", async () => {
89
+ const extensionPort = await getFreePort();
90
+ const httpPort = await getFreePort();
91
+ proc = spawnServer(extensionPort, httpPort);
92
+ const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
93
+ expect(portTaken).toBe(true);
94
+ proc.kill("SIGINT");
95
+ const exitCode = await waitForExit(proc, 5000);
96
+ expect(exitCode).not.toBeNull();
97
+ const free = await isPortFree(extensionPort, HOST);
98
+ expect(free).toBe(true);
99
+ }, 20000);
100
+ it("releases HTTP and extension ports after SIGTERM in http transport", async () => {
101
+ const extensionPort = await getFreePort();
102
+ const httpPort = await getFreePort();
103
+ proc = spawnServer(extensionPort, httpPort, "http");
104
+ const extTaken = await waitForPortHeld(extensionPort, HOST, 5000);
105
+ const httpTaken = await waitForPortHeld(httpPort, HOST, 5000);
106
+ expect(extTaken).toBe(true);
107
+ expect(httpTaken).toBe(true);
108
+ proc.kill("SIGTERM");
109
+ const exitCode = await waitForExit(proc, 5000);
110
+ expect(exitCode).not.toBeNull();
111
+ expect(await isPortFree(extensionPort, HOST)).toBe(true);
112
+ expect(await isPortFree(httpPort, HOST)).toBe(true);
113
+ }, 20000);
114
+ it("releases extension port after SIGINT with a live WebSocket client", async () => {
115
+ const extensionPort = await getFreePort();
116
+ const httpPort = await getFreePort();
117
+ proc = spawnServer(extensionPort, httpPort);
118
+ const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
119
+ expect(portTaken).toBe(true);
120
+ const ws = new WebSocket(`ws://${HOST}:${extensionPort}`);
121
+ await new Promise((resolve, reject) => {
122
+ ws.once("open", () => resolve());
123
+ ws.once("error", reject);
124
+ });
125
+ proc.kill("SIGINT");
126
+ const exitCode = await waitForExit(proc, 5000);
127
+ expect(exitCode).not.toBeNull();
128
+ try {
129
+ ws.terminate();
130
+ }
131
+ catch {
132
+ // ignore
133
+ }
134
+ const free = await isPortFree(extensionPort, HOST);
135
+ expect(free).toBe(true);
136
+ }, 20000);
137
+ it("releases extension port when stdio host closes stdin pipe", async () => {
138
+ const extensionPort = await getFreePort();
139
+ const httpPort = await getFreePort();
140
+ proc = spawnServer(extensionPort, httpPort);
141
+ const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
142
+ expect(portTaken).toBe(true);
143
+ proc.stdin.end();
144
+ const exitCode = await waitForExit(proc, 5000);
145
+ expect(exitCode).not.toBeNull();
146
+ const free = await isPortFree(extensionPort, HOST);
147
+ expect(free).toBe(true);
148
+ }, 20000);
149
+ });
@@ -0,0 +1,35 @@
1
+ const SEMVER_HEAD = /^(\d+)\.(\d+)\.(\d+)/;
2
+ export function parseVersion(raw) {
3
+ const m = SEMVER_HEAD.exec(raw);
4
+ if (!m)
5
+ return null;
6
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
7
+ }
8
+ export function compareVersion(a, b) {
9
+ for (let i = 0; i < 3; i++) {
10
+ if (a[i] < b[i])
11
+ return -1;
12
+ if (a[i] > b[i])
13
+ return 1;
14
+ }
15
+ return 0;
16
+ }
17
+ export function isBelowFloor(v, floor) {
18
+ const parsed = parseVersion(floor);
19
+ if (!parsed)
20
+ return false;
21
+ return compareVersion(v, parsed) < 0;
22
+ }
23
+ export function isInRange(v, r) {
24
+ const min = parseVersion(r.min);
25
+ if (!min)
26
+ return false;
27
+ if (compareVersion(v, min) < 0)
28
+ return false;
29
+ if (r.maxExclusive === null)
30
+ return true;
31
+ const max = parseVersion(r.maxExclusive);
32
+ if (!max)
33
+ return true;
34
+ return compareVersion(v, max) < 0;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawio-mcp-server",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Provides Draw.io services to MCP Clients",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -34,7 +34,7 @@
34
34
  "@hono/node-server": "1.19.13",
35
35
  "@modelcontextprotocol/sdk": "1.29.0",
36
36
  "cachedir": "2.4.0",
37
- "hono": "4.12.23",
37
+ "hono": "4.12.25",
38
38
  "nanoid": "5.1.6",
39
39
  "node-forge": "1.4.0",
40
40
  "unzipper": "0.12.3",
@@ -60,15 +60,16 @@
60
60
  "ts-jest": "29.4.9",
61
61
  "typescript": "5.9.3",
62
62
  "drawio-mcp-dev-proxy": "1.0.0",
63
- "drawio-mcp-plugin": "2.1.0"
63
+ "drawio-mcp-plugin": "2.2.0"
64
64
  },
65
65
  "scripts": {
66
- "build": "rimraf build && tsc && mkdir -p build/plugin && cp node_modules/drawio-mcp-plugin/dist/mcp-plugin.js build/plugin/mcp-plugin.js",
66
+ "vendor:compat": "rimraf src/vendored/compat && mkdir -p src/vendored/compat && cp ../drawio-mcp-compat/src/index.ts src/vendored/compat/index.ts",
67
+ "build": "pnpm run vendor:compat && rimraf build && tsc && mkdir -p build/plugin && cp node_modules/drawio-mcp-plugin/dist/mcp-plugin.js build/plugin/mcp-plugin.js",
67
68
  "ci": "pnpm install --frozen-lockfile",
68
- "dev": "tsc --watch",
69
- "dev:server": "concurrently --kill-others-on-fail -n tsc,node -c cyan,green \"tsc --watch --preserveWatchOutput\" \"node --watch --watch-path=build build/index.js\"",
69
+ "dev": "pnpm run vendor:compat && tsc --watch",
70
+ "dev:server": "pnpm run vendor:compat && concurrently --kill-others-on-fail -n tsc,node -c cyan,green \"tsc --watch --preserveWatchOutput\" \"node --watch --watch-path=build build/index.js\"",
70
71
  "inspect": "HOST=0.0.0.0 pnpx @modelcontextprotocol/inspector --config ./inspector.json --server drawio",
71
- "lint": "biome check src/ && tsc --noEmit",
72
+ "lint": "pnpm run vendor:compat && biome check src/ && tsc --noEmit",
72
73
  "lint:fix": "biome check --write src/",
73
74
  "prefetch-assets": "node build/prefetch-assets.js",
74
75
  "format": "prettier --write \"**/*.ts\"",