drawio-mcp-server 2.0.4 → 2.1.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 +29 -3
- package/build/assets/downloader.js +16 -17
- package/build/config.js +253 -5
- package/build/config.test.js +449 -1
- package/build/emitter_bus.js +3 -0
- package/build/emitter_bus.test.js +7 -0
- package/build/index.capabilities.test.js +45 -0
- package/build/index.js +416 -82
- package/build/install-desktop-plugin.js +56 -0
- package/build/install-desktop-plugin.test.js +66 -0
- package/build/multi-transport.test.js +117 -0
- package/build/plugin/mcp-plugin.js +2180 -1105
- package/build/prefetch-assets.js +6 -5
- package/build/real-environment/document-targeting.test.js +124 -0
- package/build/real-environment/export-diagram.test.js +1 -1
- package/build/real-environment/harness.js +118 -31
- package/build/real-environment/import-mermaid.test.js +81 -0
- package/build/real-environment/pages-and-concurrency.test.js +448 -0
- package/build/real-environment/shapes.test.js +58 -0
- package/build/real-environment/tools.js +51 -6
- package/build/register-tool.js +31 -0
- package/build/request_queue.js +29 -0
- package/build/request_queue.test.js +98 -0
- package/build/stdio-transport-purity.test.js +88 -0
- package/build/strip-schema.js +61 -0
- package/build/tls/expiry.js +14 -0
- package/build/tls/expiry.test.js +54 -0
- package/build/tls/generate.js +123 -0
- package/build/tls/generate.test.js +115 -0
- package/build/tls/index.js +80 -0
- package/build/tls/index.test.js +141 -0
- package/build/tls/install-hint.js +45 -0
- package/build/tls/install-hint.test.js +32 -0
- package/build/tls/load.js +18 -0
- package/build/tls/load.test.js +40 -0
- package/build/tls/paths.js +30 -0
- package/build/tls/paths.test.js +53 -0
- package/build/tls/san.js +27 -0
- package/build/tls/san.test.js +72 -0
- package/build/tool-registry.test.js +823 -0
- package/build/tool.js +74 -15
- package/build/tool.test.js +250 -7
- package/build/tools/add-cell-of-shape.js +4 -2
- package/build/tools/add-edge.js +3 -1
- package/build/tools/add-rectangle.js +4 -2
- package/build/tools/copy-page.js +13 -0
- package/build/tools/create-layer.js +4 -2
- package/build/tools/create-page.js +8 -0
- package/build/tools/delete-cell-by-id.js +4 -2
- package/build/tools/edit-cell.js +3 -1
- package/build/tools/edit-edge.js +3 -1
- package/build/tools/export-diagram.js +7 -3
- package/build/tools/get-active-layer.js +4 -1
- package/build/tools/get-current-page.js +5 -0
- package/build/tools/get-selected-cell.js +4 -1
- package/build/tools/import-diagram.js +12 -2
- package/build/tools/import-mermaid.js +31 -0
- package/build/tools/index.js +14 -0
- package/build/tools/list-documents.js +17 -0
- package/build/tools/list-layers.js +4 -1
- package/build/tools/list-paged-model.js +4 -3
- package/build/tools/list-pages.js +5 -0
- package/build/tools/move-cell-to-layer.js +3 -1
- package/build/tools/rename-page.js +10 -0
- package/build/tools/set-active-layer.js +4 -2
- package/build/tools/set-cell-data.js +3 -1
- package/build/tools/set-cell-parent.js +3 -1
- package/build/tools/set-cell-shape.js +3 -1
- package/build/tools/shared.js +35 -0
- package/build/tools/shared.test.js +29 -0
- package/package.json +12 -4
package/build/index.js
CHANGED
|
@@ -7,25 +7,31 @@ import { Hono } from "hono";
|
|
|
7
7
|
import { cors } from "hono/cors";
|
|
8
8
|
import EventEmitter from "node:events";
|
|
9
9
|
import { createServer } from "node:net";
|
|
10
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { readFileSync, existsSync, statSync, readdirSync, realpathSync, } from "node:fs";
|
|
13
14
|
import { WebSocket, WebSocketServer } from "ws";
|
|
14
|
-
const VERSION = process.env.npm_package_version ?? "2.0
|
|
15
|
-
import { buildConfig, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
|
|
15
|
+
const VERSION = process.env.npm_package_version ?? "2.1.0";
|
|
16
|
+
import { buildConfig, defaultConfig, hasFlag, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
|
|
17
|
+
import { installDesktopPlugin } from "./install-desktop-plugin.js";
|
|
16
18
|
import { bus_reply_stream, bus_request_stream, } from "./types.js";
|
|
17
19
|
import { create_bus } from "./emitter_bus.js";
|
|
18
20
|
import { nanoid_id_generator } from "./nanoid_id_generator.js";
|
|
21
|
+
import { create_request_queue } from "./request_queue.js";
|
|
19
22
|
import { create_logger as create_console_logger } from "./mcp_console_logger.js";
|
|
20
23
|
import { create_logger as create_server_logger, validLogLevels, } from "./mcp_server_logger.js";
|
|
21
24
|
import { getLocalPluginPath, isUsingLocalAssets, getAssetRoot, ensureAssets, } from "./assets/index.js";
|
|
22
25
|
import { registerTools } from "./tools/index.js";
|
|
26
|
+
import { createServerWithSchemaStripping } from "./register-tool.js";
|
|
27
|
+
import { target_document_field } from "./tools/shared.js";
|
|
28
|
+
import { resolveTlsMaterial } from "./tls/index.js";
|
|
23
29
|
const fatalLog = create_console_logger();
|
|
24
30
|
/**
|
|
25
31
|
* Display help message and exit
|
|
26
32
|
*/
|
|
27
33
|
function showHelp() {
|
|
28
|
-
|
|
34
|
+
fatalLog.log("info", `
|
|
29
35
|
Draw.io MCP Server (${VERSION})
|
|
30
36
|
|
|
31
37
|
Usage: drawio-mcp-server [options]
|
|
@@ -35,7 +41,15 @@ Options:
|
|
|
35
41
|
--editor, -e Enable draw.io editor endpoint
|
|
36
42
|
--http-port HTTP server port for Streamable HTTP transport (default: 3000)
|
|
37
43
|
--transport Transport type: stdio, http (default: stdio)
|
|
38
|
-
--asset-path <path>
|
|
44
|
+
--asset-path <path> Custom path for downloaded assets
|
|
45
|
+
--host <ip> Bind address for all servers (default: OS-assigned, e.g. 127.0.0.1 or ::1)
|
|
46
|
+
--logger <mode> Logger mode: console (stderr) or mcp-server (MCP notifications/message) (default: console)
|
|
47
|
+
--tls Enable TLS (HTTPS + WSS) on HTTP and WebSocket endpoints
|
|
48
|
+
--tls-cert <path> Manual TLS cert PEM (requires --tls and --tls-key)
|
|
49
|
+
--tls-key <path> Manual TLS key PEM (requires --tls and --tls-cert)
|
|
50
|
+
--tls-auto Auto-generate self-signed cert via local CA (requires --tls)
|
|
51
|
+
--tls-dir <path> Override XDG data dir for TLS material (default: per-OS)
|
|
52
|
+
--install-desktop-plugin Install mcp-plugin.js into draw.io desktop's plugins directory, then start normally
|
|
39
53
|
--help, -h Show this help message
|
|
40
54
|
|
|
41
55
|
Examples:
|
|
@@ -45,33 +59,21 @@ Examples:
|
|
|
45
59
|
drawio-mcp-server --editor # Enable draw.io editor endpoint
|
|
46
60
|
drawio-mcp-server -e --http # Enable editor and HTTP transport
|
|
47
61
|
drawio-mcp-server --editor --asset-path /data/assets # Use custom asset path
|
|
62
|
+
drawio-mcp-server --editor --tls --tls-auto # HTTPS editor with auto self-signed cert
|
|
63
|
+
drawio-mcp-server --install-desktop-plugin # Install plugin into draw.io desktop, then run server
|
|
48
64
|
`);
|
|
49
65
|
process.exit(0);
|
|
50
66
|
}
|
|
51
67
|
// No PORT constant needed - using dynamic config
|
|
52
|
-
async function checkPortAvailable(port) {
|
|
68
|
+
async function checkPortAvailable(port, host) {
|
|
53
69
|
return new Promise((resolve) => {
|
|
54
70
|
const server = createServer();
|
|
55
|
-
server.listen(port, () => {
|
|
71
|
+
server.listen({ port, ...(host !== undefined ? { host } : {}) }, () => {
|
|
56
72
|
server.close(() => resolve(true));
|
|
57
73
|
});
|
|
58
74
|
server.on("error", () => resolve(false));
|
|
59
75
|
});
|
|
60
76
|
}
|
|
61
|
-
const logger_type = process.env.LOGGER_TYPE;
|
|
62
|
-
let capabilities = {
|
|
63
|
-
resources: {},
|
|
64
|
-
tools: {},
|
|
65
|
-
};
|
|
66
|
-
if (logger_type === "mcp_server") {
|
|
67
|
-
capabilities = {
|
|
68
|
-
...capabilities,
|
|
69
|
-
logging: {
|
|
70
|
-
setLevels: true,
|
|
71
|
-
levels: validLogLevels,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
77
|
async function start_stdio_transport(createServer, log) {
|
|
76
78
|
const server = createServer();
|
|
77
79
|
const transport = new StdioServerTransport();
|
|
@@ -95,11 +97,13 @@ function setupCors(app) {
|
|
|
95
97
|
function registerHealthRoute(app) {
|
|
96
98
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
97
99
|
}
|
|
98
|
-
function registerConfigRoute(app, config) {
|
|
99
|
-
app.get("/api/config", (c) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
function registerConfigRoute(app, config, scheme) {
|
|
101
|
+
app.get("/api/config", (c) => {
|
|
102
|
+
const serverUrl = `${scheme}://localhost:${config.httpPort}`;
|
|
103
|
+
return c.json(config.webSocketUrl
|
|
104
|
+
? { serverUrl, websocketUrl: config.webSocketUrl }
|
|
105
|
+
: { serverUrl, websocketPort: config.extensionPort });
|
|
106
|
+
});
|
|
103
107
|
}
|
|
104
108
|
function registerEditorRoutes(app, config, log) {
|
|
105
109
|
const assetConfig = {
|
|
@@ -191,48 +195,70 @@ function registerMcpRoute(app, createServer, disposeMcpServer) {
|
|
|
191
195
|
return transport.handleRequest(c.req.raw);
|
|
192
196
|
});
|
|
193
197
|
}
|
|
194
|
-
function createHttpApp(log, config, features, createServer, disposeMcpServer) {
|
|
198
|
+
function createHttpApp(log, config, features, createServer, disposeMcpServer, tlsMaterial) {
|
|
195
199
|
const app = new Hono();
|
|
196
200
|
setupCors(app);
|
|
201
|
+
const scheme = tlsMaterial ? "https" : "http";
|
|
197
202
|
if (features.enableHealth)
|
|
198
203
|
registerHealthRoute(app);
|
|
199
204
|
if (features.enableConfig)
|
|
200
|
-
registerConfigRoute(app, config);
|
|
205
|
+
registerConfigRoute(app, config, scheme);
|
|
201
206
|
if (features.enableMcp)
|
|
202
207
|
registerMcpRoute(app, createServer, disposeMcpServer);
|
|
203
208
|
if (features.enableEditor)
|
|
204
209
|
registerEditorRoutes(app, config, log);
|
|
205
210
|
return { app };
|
|
206
211
|
}
|
|
207
|
-
async function startHttpServer(createServer, disposeMcpServer, log, httpPort, config, features) {
|
|
208
|
-
const { app } = createHttpApp(log, config, features, createServer, disposeMcpServer);
|
|
212
|
+
async function startHttpServer(createServer, disposeMcpServer, log, httpPort, config, features, tlsMaterial) {
|
|
213
|
+
const { app } = createHttpApp(log, config, features, createServer, disposeMcpServer, tlsMaterial);
|
|
209
214
|
const httpServer = serve({
|
|
210
215
|
fetch: app.fetch,
|
|
211
216
|
port: httpPort,
|
|
217
|
+
...(config.host !== undefined ? { hostname: config.host } : {}),
|
|
218
|
+
...(tlsMaterial
|
|
219
|
+
? {
|
|
220
|
+
createServer: createHttpsServer,
|
|
221
|
+
serverOptions: { cert: tlsMaterial.cert, key: tlsMaterial.key },
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
212
224
|
});
|
|
213
225
|
const listeningPort = httpPort === 0
|
|
214
226
|
? (httpServer.address()?.port ?? httpPort)
|
|
215
227
|
: httpPort;
|
|
216
|
-
|
|
228
|
+
const scheme = tlsMaterial ? "https" : "http";
|
|
229
|
+
log.debug(`Draw.io MCP Server HTTP active on port ${listeningPort} (${scheme})`);
|
|
217
230
|
if (features.enableMcp) {
|
|
218
|
-
log.debug(`MCP endpoint:
|
|
231
|
+
log.debug(`MCP endpoint: ${scheme}://localhost:${listeningPort}/mcp`);
|
|
219
232
|
}
|
|
220
233
|
if (features.enableEditor) {
|
|
221
|
-
log.debug(`Editor:
|
|
234
|
+
log.debug(`Editor: ${scheme}://localhost:${listeningPort}/`);
|
|
222
235
|
}
|
|
223
236
|
return {
|
|
224
237
|
server: httpServer,
|
|
225
238
|
port: listeningPort,
|
|
226
239
|
};
|
|
227
240
|
}
|
|
228
|
-
export function createDrawioMcpApp(
|
|
241
|
+
export function createDrawioMcpApp(options) {
|
|
242
|
+
const config = options?.config ?? defaultConfig();
|
|
229
243
|
const emitter = new EventEmitter();
|
|
230
|
-
const
|
|
244
|
+
const connectionIdGenerator = nanoid_id_generator();
|
|
231
245
|
const mcpServers = new Set();
|
|
246
|
+
const capabilities = {
|
|
247
|
+
resources: {},
|
|
248
|
+
tools: {},
|
|
249
|
+
};
|
|
250
|
+
if (config.logger === "mcp-server") {
|
|
251
|
+
capabilities.logging = {
|
|
252
|
+
setLevels: true,
|
|
253
|
+
levels: validLogLevels,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const conns = new Map();
|
|
257
|
+
const DOCUMENT_SYNC_TIMEOUT_MS = 1000;
|
|
232
258
|
// Lazily resolved log — uses console logger until a server logger is
|
|
233
|
-
// explicitly requested via
|
|
234
|
-
//
|
|
235
|
-
let _log =
|
|
259
|
+
// explicitly requested via --logger mcp-server, in which case the first
|
|
260
|
+
// McpServer created will be used for the logger binding.
|
|
261
|
+
let _log = options?.log;
|
|
236
262
|
let _serverLoggerBound = false;
|
|
237
263
|
function getLog() {
|
|
238
264
|
if (_log)
|
|
@@ -248,13 +274,180 @@ export function createDrawioMcpApp(overrides) {
|
|
|
248
274
|
};
|
|
249
275
|
const bus = create_bus(lazyLog)(emitter);
|
|
250
276
|
const id_generator = nanoid_id_generator();
|
|
277
|
+
const request_queue = create_request_queue(lazyLog);
|
|
278
|
+
function normalizeOptionalString(value) {
|
|
279
|
+
if (value === undefined || value === null || value === "") {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return String(value);
|
|
283
|
+
}
|
|
284
|
+
function normalizeCurrentPage(value) {
|
|
285
|
+
if (!value || typeof value !== "object") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const record = value;
|
|
289
|
+
const id = normalizeOptionalString(record.id);
|
|
290
|
+
const name = normalizeOptionalString(record.name);
|
|
291
|
+
const index = Number(record.index);
|
|
292
|
+
if (!id || !name || !Number.isInteger(index) || index < 0) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
index,
|
|
297
|
+
id,
|
|
298
|
+
name,
|
|
299
|
+
is_current: true,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function normalizeDocumentState(value) {
|
|
303
|
+
if (!value || typeof value !== "object") {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const record = value;
|
|
307
|
+
const id = normalizeOptionalString(record.id);
|
|
308
|
+
if (!id) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
const page_count = Number(record.page_count);
|
|
312
|
+
return {
|
|
313
|
+
id,
|
|
314
|
+
title: normalizeOptionalString(record.title),
|
|
315
|
+
mode: normalizeOptionalString(record.mode),
|
|
316
|
+
hash: normalizeOptionalString(record.hash),
|
|
317
|
+
file_url: normalizeOptionalString(record.file_url),
|
|
318
|
+
page_count: Number.isInteger(page_count) && page_count >= 0 ? page_count : 0,
|
|
319
|
+
current_page: normalizeCurrentPage(record.current_page),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function listKnownDocuments() {
|
|
323
|
+
return [...conns.values()]
|
|
324
|
+
.map((entry) => entry.document)
|
|
325
|
+
.filter((document) => document !== null);
|
|
326
|
+
}
|
|
327
|
+
function findConnectionByDocumentId(documentId) {
|
|
328
|
+
return [...conns.values()].find((entry) => entry.document?.id === documentId);
|
|
329
|
+
}
|
|
330
|
+
function flushSyncWaiters(entry) {
|
|
331
|
+
for (const resolve of [...entry.sync_waiters]) {
|
|
332
|
+
try {
|
|
333
|
+
resolve();
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// ignore
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
entry.sync_waiters.clear();
|
|
340
|
+
}
|
|
341
|
+
function sendControlMessage(entry, control) {
|
|
342
|
+
if (entry.ws.readyState !== WebSocket.OPEN) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
entry.ws.send(JSON.stringify({
|
|
346
|
+
__control: control,
|
|
347
|
+
}));
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
function requestDocumentSync(entry) {
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
let settled = false;
|
|
353
|
+
let timeout;
|
|
354
|
+
const wrappedFinish = () => {
|
|
355
|
+
if (settled) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
settled = true;
|
|
359
|
+
if (timeout) {
|
|
360
|
+
clearTimeout(timeout);
|
|
361
|
+
}
|
|
362
|
+
entry.sync_waiters.delete(wrappedFinish);
|
|
363
|
+
resolve();
|
|
364
|
+
};
|
|
365
|
+
entry.sync_waiters.add(wrappedFinish);
|
|
366
|
+
timeout = setTimeout(() => {
|
|
367
|
+
wrappedFinish();
|
|
368
|
+
}, DOCUMENT_SYNC_TIMEOUT_MS);
|
|
369
|
+
if (!sendControlMessage(entry, "sync-document-state")) {
|
|
370
|
+
wrappedFinish();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function syncAllDocuments() {
|
|
375
|
+
await Promise.all([...conns.values()].map((entry) => requestDocumentSync(entry)));
|
|
376
|
+
}
|
|
377
|
+
const document_routing = {
|
|
378
|
+
list_documents: async () => {
|
|
379
|
+
await syncAllDocuments();
|
|
380
|
+
return listKnownDocuments();
|
|
381
|
+
},
|
|
382
|
+
resolve_target_document: async (args) => {
|
|
383
|
+
const rawSelector = args.target_document;
|
|
384
|
+
if (rawSelector && typeof rawSelector === "object") {
|
|
385
|
+
const selector = rawSelector;
|
|
386
|
+
const documentId = normalizeOptionalString(selector.id);
|
|
387
|
+
if (!documentId) {
|
|
388
|
+
throw new Error("`target_document.id` is required");
|
|
389
|
+
}
|
|
390
|
+
let entry = findConnectionByDocumentId(documentId);
|
|
391
|
+
if (!entry) {
|
|
392
|
+
await syncAllDocuments();
|
|
393
|
+
entry = findConnectionByDocumentId(documentId);
|
|
394
|
+
}
|
|
395
|
+
if (!entry || !entry.document) {
|
|
396
|
+
throw new Error(`Document with ID ${documentId} was not found`);
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
connection_id: entry.connection_id,
|
|
400
|
+
target_document: { id: documentId },
|
|
401
|
+
document: entry.document,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
await syncAllDocuments();
|
|
405
|
+
const documents = listKnownDocuments();
|
|
406
|
+
if (documents.length === 0) {
|
|
407
|
+
throw new Error("No connected Draw.io documents");
|
|
408
|
+
}
|
|
409
|
+
if (documents.length > 1) {
|
|
410
|
+
throw new Error("Multiple Draw.io documents are connected. Call `list-documents` and retry with `target_document`.");
|
|
411
|
+
}
|
|
412
|
+
const document = documents[0];
|
|
413
|
+
const entry = findConnectionByDocumentId(document.id);
|
|
414
|
+
if (!entry) {
|
|
415
|
+
throw new Error(`Document with ID ${document.id} was not found`);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
connection_id: entry.connection_id,
|
|
419
|
+
target_document: { id: document.id },
|
|
420
|
+
document,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
};
|
|
251
424
|
const context = {
|
|
252
425
|
bus,
|
|
253
426
|
id_generator,
|
|
427
|
+
request_queue,
|
|
428
|
+
document_routing,
|
|
254
429
|
get log() {
|
|
255
430
|
return getLog();
|
|
256
431
|
},
|
|
257
432
|
};
|
|
433
|
+
function createDocumentScopedServer(server) {
|
|
434
|
+
return new Proxy(server, {
|
|
435
|
+
get(target, prop, receiver) {
|
|
436
|
+
if (prop !== "tool") {
|
|
437
|
+
return Reflect.get(target, prop, receiver);
|
|
438
|
+
}
|
|
439
|
+
return (name, description, params, handler) => {
|
|
440
|
+
const scopedParams = name === "list-documents"
|
|
441
|
+
? params
|
|
442
|
+
: {
|
|
443
|
+
...params,
|
|
444
|
+
target_document: target_document_field().optional(),
|
|
445
|
+
};
|
|
446
|
+
return target.tool.call(target, name, description, scopedParams, handler);
|
|
447
|
+
};
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
258
451
|
/**
|
|
259
452
|
* Factory: creates a new McpServer instance with all tools registered.
|
|
260
453
|
* Each transport must use its own McpServer since the MCP SDK only
|
|
@@ -267,14 +460,15 @@ export function createDrawioMcpApp(overrides) {
|
|
|
267
460
|
}, {
|
|
268
461
|
capabilities,
|
|
269
462
|
});
|
|
270
|
-
// Bind the mcp_server logger to the first server created
|
|
271
|
-
|
|
463
|
+
// Bind the mcp_server logger to the first server created when the
|
|
464
|
+
// mcp-server logger mode was selected.
|
|
465
|
+
if (config.logger === "mcp-server" &&
|
|
272
466
|
!_serverLoggerBound &&
|
|
273
|
-
!
|
|
467
|
+
!options?.log) {
|
|
274
468
|
_log = create_server_logger(server);
|
|
275
469
|
_serverLoggerBound = true;
|
|
276
470
|
}
|
|
277
|
-
registerTools(server, context);
|
|
471
|
+
registerTools(createDocumentScopedServer(createServerWithSchemaStripping(server)), context);
|
|
278
472
|
mcpServers.add(server);
|
|
279
473
|
return server;
|
|
280
474
|
}
|
|
@@ -286,42 +480,140 @@ export function createDrawioMcpApp(overrides) {
|
|
|
286
480
|
function disposeMcpServer(server) {
|
|
287
481
|
mcpServers.delete(server);
|
|
288
482
|
}
|
|
483
|
+
function createDisconnectedDocumentError(event) {
|
|
484
|
+
const targetDocumentId = typeof event.target_document?.id ===
|
|
485
|
+
"string" && event.target_document.id
|
|
486
|
+
? event.target_document.id
|
|
487
|
+
: null;
|
|
488
|
+
const targetConnectionId = typeof event.__target_connection_id === "string"
|
|
489
|
+
? event.__target_connection_id
|
|
490
|
+
: null;
|
|
491
|
+
const targetLabel = targetDocumentId
|
|
492
|
+
? `Target document ${targetDocumentId}`
|
|
493
|
+
: targetConnectionId
|
|
494
|
+
? `Target connection ${targetConnectionId}`
|
|
495
|
+
: "Target Draw.io connection";
|
|
496
|
+
return new Error(`${targetLabel} is no longer connected; call list-documents and retry`);
|
|
497
|
+
}
|
|
289
498
|
const bus_to_ws_forwarder_listener = (event) => {
|
|
499
|
+
const targetConnectionId = typeof event?.__target_connection_id === "string"
|
|
500
|
+
? event.__target_connection_id
|
|
501
|
+
: null;
|
|
502
|
+
if (targetConnectionId) {
|
|
503
|
+
const entry = conns.get(targetConnectionId);
|
|
504
|
+
getLog().debug(`[bridge] forwarding message to #${targetConnectionId}`, event);
|
|
505
|
+
if (!entry) {
|
|
506
|
+
getLog().debug(`[bridge] target connection ${targetConnectionId} not found`);
|
|
507
|
+
throw createDisconnectedDocumentError(event);
|
|
508
|
+
}
|
|
509
|
+
if (entry.ws.readyState !== WebSocket.OPEN) {
|
|
510
|
+
flushSyncWaiters(entry);
|
|
511
|
+
conns.delete(targetConnectionId);
|
|
512
|
+
throw createDisconnectedDocumentError(event);
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
entry.ws.send(JSON.stringify(event));
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
getLog().debug("[bridge] error forwarding request", error);
|
|
519
|
+
flushSyncWaiters(entry);
|
|
520
|
+
conns.delete(targetConnectionId);
|
|
521
|
+
throw createDisconnectedDocumentError(event);
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
290
525
|
getLog().debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
|
|
291
|
-
for (const
|
|
292
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
293
|
-
|
|
526
|
+
for (const [connectionId, entry] of [...conns.entries()]) {
|
|
527
|
+
if (entry.ws.readyState !== WebSocket.OPEN) {
|
|
528
|
+
flushSyncWaiters(entry);
|
|
529
|
+
conns.delete(connectionId);
|
|
294
530
|
continue;
|
|
295
531
|
}
|
|
296
532
|
try {
|
|
297
|
-
ws.send(JSON.stringify(event));
|
|
533
|
+
entry.ws.send(JSON.stringify(event));
|
|
298
534
|
}
|
|
299
|
-
catch (
|
|
300
|
-
getLog().debug("[bridge] error forwarding request",
|
|
301
|
-
|
|
535
|
+
catch (error) {
|
|
536
|
+
getLog().debug("[bridge] error forwarding request", error);
|
|
537
|
+
flushSyncWaiters(entry);
|
|
538
|
+
conns.delete(connectionId);
|
|
302
539
|
}
|
|
303
540
|
}
|
|
304
541
|
};
|
|
305
542
|
emitter.on(bus_request_stream, bus_to_ws_forwarder_listener);
|
|
543
|
+
const tlsMaterial = resolveTlsMaterial({
|
|
544
|
+
config: {
|
|
545
|
+
tlsEnabled: config.tlsEnabled,
|
|
546
|
+
tlsAuto: config.tlsAuto,
|
|
547
|
+
tlsCert: config.tlsCert,
|
|
548
|
+
tlsKey: config.tlsKey,
|
|
549
|
+
tlsDir: config.tlsDir,
|
|
550
|
+
host: config.host,
|
|
551
|
+
},
|
|
552
|
+
log: (msg) => getLog().log("info", msg),
|
|
553
|
+
});
|
|
306
554
|
let wsServer;
|
|
555
|
+
let wssHttpsServer;
|
|
307
556
|
let httpServer;
|
|
308
|
-
async function startWebSocketServer(extensionPort = 3333) {
|
|
309
|
-
getLog().debug(`Draw.io MCP Server (${VERSION}) starting (WebSocket extension port: ${extensionPort})`);
|
|
557
|
+
async function startWebSocketServer(extensionPort = 3333, host) {
|
|
558
|
+
getLog().debug(`Draw.io MCP Server (${VERSION}) starting (${tlsMaterial ? "WSS" : "WebSocket"} extension port: ${extensionPort})`);
|
|
310
559
|
if (extensionPort !== 0) {
|
|
311
|
-
const isPortAvailable = await checkPortAvailable(extensionPort);
|
|
560
|
+
const isPortAvailable = await checkPortAvailable(extensionPort, host);
|
|
312
561
|
if (!isPortAvailable) {
|
|
313
562
|
throw new Error(`[start_websocket_server] Error: Port ${extensionPort} is already in use. Please stop the process using this port and try again.`);
|
|
314
563
|
}
|
|
315
564
|
}
|
|
316
|
-
|
|
565
|
+
if (tlsMaterial) {
|
|
566
|
+
const httpsServer = createHttpsServer({
|
|
567
|
+
cert: tlsMaterial.cert,
|
|
568
|
+
key: tlsMaterial.key,
|
|
569
|
+
});
|
|
570
|
+
// Destroy sockets that send plain-text to the TLS port so clients get
|
|
571
|
+
// a prompt close rather than a half-open connection.
|
|
572
|
+
httpsServer.on("tlsClientError", (_err, tlsSocket) => {
|
|
573
|
+
tlsSocket.destroy();
|
|
574
|
+
});
|
|
575
|
+
await new Promise((resolve, reject) => {
|
|
576
|
+
httpsServer.once("error", reject);
|
|
577
|
+
httpsServer.listen({
|
|
578
|
+
port: extensionPort,
|
|
579
|
+
...(host !== undefined ? { host } : {}),
|
|
580
|
+
}, () => {
|
|
581
|
+
httpsServer.off("error", reject);
|
|
582
|
+
resolve();
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
wssHttpsServer = httpsServer;
|
|
586
|
+
wsServer = new WebSocketServer({ server: httpsServer });
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
wsServer = new WebSocketServer({
|
|
590
|
+
port: extensionPort,
|
|
591
|
+
...(host !== undefined ? { host } : {}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
317
594
|
wsServer.on("connection", (ws) => {
|
|
318
|
-
|
|
319
|
-
|
|
595
|
+
const connection_id = connectionIdGenerator.generate();
|
|
596
|
+
const entry = {
|
|
597
|
+
connection_id,
|
|
598
|
+
ws,
|
|
599
|
+
document: null,
|
|
600
|
+
updated_at: Date.now(),
|
|
601
|
+
sync_waiters: new Set(),
|
|
602
|
+
};
|
|
603
|
+
getLog().debug(`[ws_handler] WebSocket client ${connection_id} connected, presumably MCP Extension!`);
|
|
604
|
+
conns.set(connection_id, entry);
|
|
605
|
+
sendControlMessage(entry, "sync-document-state");
|
|
320
606
|
ws.on("message", (data) => {
|
|
321
607
|
const str = typeof data === "string" ? data : data.toString();
|
|
322
608
|
try {
|
|
323
609
|
const json = JSON.parse(str);
|
|
324
610
|
getLog().debug(`[ws] received from Extension`, json);
|
|
611
|
+
if (json?.__control === "document-state") {
|
|
612
|
+
entry.document = normalizeDocumentState(json.document);
|
|
613
|
+
entry.updated_at = Date.now();
|
|
614
|
+
flushSyncWaiters(entry);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
325
617
|
emitter.emit(bus_reply_stream, json);
|
|
326
618
|
}
|
|
327
619
|
catch (error) {
|
|
@@ -329,35 +621,42 @@ export function createDrawioMcpApp(overrides) {
|
|
|
329
621
|
}
|
|
330
622
|
});
|
|
331
623
|
ws.on("close", (code) => {
|
|
332
|
-
|
|
333
|
-
|
|
624
|
+
flushSyncWaiters(entry);
|
|
625
|
+
conns.delete(connection_id);
|
|
626
|
+
getLog().debug(`[ws_handler] WebSocket client ${connection_id} closed with code ${code}`);
|
|
334
627
|
});
|
|
335
628
|
ws.on("error", (error) => {
|
|
336
629
|
getLog().debug(`[ws_handler] WebSocket client error`, error);
|
|
337
|
-
|
|
630
|
+
flushSyncWaiters(entry);
|
|
631
|
+
conns.delete(connection_id);
|
|
338
632
|
});
|
|
339
633
|
});
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
634
|
+
if (!tlsMaterial) {
|
|
635
|
+
await new Promise((resolve, reject) => {
|
|
636
|
+
const onListening = () => {
|
|
637
|
+
wsServer?.off("error", onError);
|
|
638
|
+
resolve();
|
|
639
|
+
};
|
|
640
|
+
const onError = (error) => {
|
|
641
|
+
wsServer?.off("listening", onListening);
|
|
642
|
+
reject(error);
|
|
643
|
+
};
|
|
644
|
+
wsServer?.once("listening", onListening);
|
|
645
|
+
wsServer?.once("error", onError);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const address = wsServer?.address();
|
|
649
|
+
getLog().debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
|
|
650
|
+
// wsServer is always set by one of the two branches above
|
|
651
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
354
652
|
return wsServer;
|
|
355
653
|
}
|
|
356
654
|
async function close() {
|
|
357
655
|
emitter.off(bus_request_stream, bus_to_ws_forwarder_listener);
|
|
358
|
-
for (const
|
|
656
|
+
for (const entry of [...conns.values()]) {
|
|
359
657
|
try {
|
|
360
|
-
|
|
658
|
+
flushSyncWaiters(entry);
|
|
659
|
+
entry.ws.close();
|
|
361
660
|
}
|
|
362
661
|
catch {
|
|
363
662
|
// ignore
|
|
@@ -380,6 +679,15 @@ export function createDrawioMcpApp(overrides) {
|
|
|
380
679
|
});
|
|
381
680
|
wsServer = undefined;
|
|
382
681
|
}
|
|
682
|
+
if (wssHttpsServer) {
|
|
683
|
+
const hs = wssHttpsServer;
|
|
684
|
+
wssHttpsServer = undefined;
|
|
685
|
+
// Forcefully terminate all connections (including half-open TLS
|
|
686
|
+
// connections from clients that called socket.end() before the
|
|
687
|
+
// WebSocket upgrade) so the server closes immediately.
|
|
688
|
+
hs.closeAllConnections?.();
|
|
689
|
+
await new Promise((resolve) => hs.close(() => resolve()));
|
|
690
|
+
}
|
|
383
691
|
if (httpServer) {
|
|
384
692
|
await new Promise((resolve, reject) => {
|
|
385
693
|
httpServer?.close((error) => {
|
|
@@ -406,38 +714,64 @@ export function createDrawioMcpApp(overrides) {
|
|
|
406
714
|
await start_stdio_transport(createMcpServer, getLog());
|
|
407
715
|
},
|
|
408
716
|
startHttpServer: async (httpPort, config, features) => {
|
|
409
|
-
const started = await startHttpServer(createMcpServer, disposeMcpServer, getLog(), httpPort, config, features);
|
|
717
|
+
const started = await startHttpServer(createMcpServer, disposeMcpServer, getLog(), httpPort, config, features, tlsMaterial);
|
|
410
718
|
httpServer = started.server;
|
|
411
719
|
return started;
|
|
412
720
|
},
|
|
413
721
|
};
|
|
414
722
|
}
|
|
723
|
+
async function runInstallDesktopPlugin() {
|
|
724
|
+
try {
|
|
725
|
+
const result = await installDesktopPlugin();
|
|
726
|
+
fatalLog.log("info", `Plugin installed at ${result.installedPath}${result.overwrote ? " (overwrote existing)" : ""}.
|
|
727
|
+
|
|
728
|
+
To enable in draw.io desktop:
|
|
729
|
+
1. Launch draw.io with: --enable-plugins
|
|
730
|
+
2. Open: Extras -> Configuration -> Preferences (Configuration JSON dialog)
|
|
731
|
+
3. Add this entry to the JSON (merge with any existing keys):
|
|
732
|
+
{ "plugins": ["mcp-plugin.js"] }
|
|
733
|
+
4. Click Save and restart draw.io.
|
|
734
|
+
|
|
735
|
+
Continuing with normal server startup...`);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
739
|
+
fatalLog.log("error", `Failed to install desktop plugin: ${message}`);
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
415
743
|
async function main() {
|
|
744
|
+
const cliArgs = process.argv.slice(2);
|
|
416
745
|
// Check if help was requested (before parsing config)
|
|
417
|
-
if (shouldShowHelp(
|
|
746
|
+
if (shouldShowHelp(cliArgs)) {
|
|
418
747
|
showHelp();
|
|
419
748
|
// never returns
|
|
420
749
|
}
|
|
750
|
+
// Run install side-effect before normal startup, so users can keep a single
|
|
751
|
+
// command in their MCP host config (install once, then run every time).
|
|
752
|
+
if (hasFlag(cliArgs, "--install-desktop-plugin")) {
|
|
753
|
+
await runInstallDesktopPlugin();
|
|
754
|
+
}
|
|
421
755
|
// Build configuration from command line args
|
|
422
756
|
const configResult = buildConfig();
|
|
423
757
|
// Handle errors from configuration parsing
|
|
424
758
|
if (configResult instanceof Error) {
|
|
425
|
-
|
|
759
|
+
fatalLog.log("error", `Error: ${configResult.message}`);
|
|
426
760
|
process.exit(1);
|
|
427
761
|
}
|
|
428
762
|
const config = configResult;
|
|
429
763
|
const features = getHttpFeatureConfig(config);
|
|
764
|
+
const app = createDrawioMcpApp({ config });
|
|
430
765
|
// Initialize assets if needed
|
|
431
766
|
if (features.enableEditor) {
|
|
432
|
-
|
|
767
|
+
app.log.debug("Initializing draw.io assets...");
|
|
433
768
|
const assetConfig = {
|
|
434
769
|
assetPath: config.assetPath,
|
|
435
770
|
};
|
|
436
|
-
await ensureAssets(assetConfig,
|
|
437
|
-
|
|
771
|
+
await ensureAssets(assetConfig, app.log);
|
|
772
|
+
app.log.debug("Assets ready!");
|
|
438
773
|
}
|
|
439
|
-
|
|
440
|
-
await app.startWebSocketServer(config.extensionPort);
|
|
774
|
+
await app.startWebSocketServer(config.extensionPort, config.host);
|
|
441
775
|
if (config.transports.indexOf("stdio") > -1) {
|
|
442
776
|
await app.startStdioTransport();
|
|
443
777
|
}
|
|
@@ -450,7 +784,7 @@ const isMainModule = process.argv[1]
|
|
|
450
784
|
: false;
|
|
451
785
|
if (isMainModule) {
|
|
452
786
|
main().catch((error) => {
|
|
453
|
-
fatalLog.
|
|
787
|
+
fatalLog.log("error", "Fatal error in main():", error);
|
|
454
788
|
process.exit(1);
|
|
455
789
|
});
|
|
456
790
|
}
|