drawio-mcp-server 2.1.0 → 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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Experimental: integration with the **draw.io desktop (Electron) app** is in prog
41
41
 
42
42
  ## Requirements
43
43
 
44
- - **Node.js** (v20 or higher) - Runtime environment for the MCP server
44
+ - **Node.js** (v22 or higher; tested against v22 LTS and v24 LTS) - Runtime environment for the MCP server
45
45
  - **MCP client** - Claude Desktop, Claude Code, Zed, Codex, OpenCode, or any MCP-compatible host
46
46
 
47
47
  ### For Built-in Editor
@@ -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.0";
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}`);