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 +1 -1
- 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 +112 -10
- package/build/plugin/mcp-plugin.js +322 -89
- package/build/register-tool.js +72 -20
- package/build/stdio-shutdown.test.js +149 -0
- package/build/vendored/compat/index.js +35 -0
- package/package.json +9 -8
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** (
|
|
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.
|
|
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
|
-
.
|
|
325
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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}`);
|