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.
- 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/stdio-shutdown.test.js +149 -0
- package/build/vendored/compat/index.js +35 -0
- package/package.json +8 -7
|
@@ -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}`);
|
|
@@ -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:
|
|
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.
|
|
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.
|
|
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.
|
|
63
|
+
"drawio-mcp-plugin": "2.2.0"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
|
-
"
|
|
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\"",
|