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.
Files changed (71) hide show
  1. package/README.md +29 -3
  2. package/build/assets/downloader.js +16 -17
  3. package/build/config.js +253 -5
  4. package/build/config.test.js +449 -1
  5. package/build/emitter_bus.js +3 -0
  6. package/build/emitter_bus.test.js +7 -0
  7. package/build/index.capabilities.test.js +45 -0
  8. package/build/index.js +416 -82
  9. package/build/install-desktop-plugin.js +56 -0
  10. package/build/install-desktop-plugin.test.js +66 -0
  11. package/build/multi-transport.test.js +117 -0
  12. package/build/plugin/mcp-plugin.js +2180 -1105
  13. package/build/prefetch-assets.js +6 -5
  14. package/build/real-environment/document-targeting.test.js +124 -0
  15. package/build/real-environment/export-diagram.test.js +1 -1
  16. package/build/real-environment/harness.js +118 -31
  17. package/build/real-environment/import-mermaid.test.js +81 -0
  18. package/build/real-environment/pages-and-concurrency.test.js +448 -0
  19. package/build/real-environment/shapes.test.js +58 -0
  20. package/build/real-environment/tools.js +51 -6
  21. package/build/register-tool.js +31 -0
  22. package/build/request_queue.js +29 -0
  23. package/build/request_queue.test.js +98 -0
  24. package/build/stdio-transport-purity.test.js +88 -0
  25. package/build/strip-schema.js +61 -0
  26. package/build/tls/expiry.js +14 -0
  27. package/build/tls/expiry.test.js +54 -0
  28. package/build/tls/generate.js +123 -0
  29. package/build/tls/generate.test.js +115 -0
  30. package/build/tls/index.js +80 -0
  31. package/build/tls/index.test.js +141 -0
  32. package/build/tls/install-hint.js +45 -0
  33. package/build/tls/install-hint.test.js +32 -0
  34. package/build/tls/load.js +18 -0
  35. package/build/tls/load.test.js +40 -0
  36. package/build/tls/paths.js +30 -0
  37. package/build/tls/paths.test.js +53 -0
  38. package/build/tls/san.js +27 -0
  39. package/build/tls/san.test.js +72 -0
  40. package/build/tool-registry.test.js +823 -0
  41. package/build/tool.js +74 -15
  42. package/build/tool.test.js +250 -7
  43. package/build/tools/add-cell-of-shape.js +4 -2
  44. package/build/tools/add-edge.js +3 -1
  45. package/build/tools/add-rectangle.js +4 -2
  46. package/build/tools/copy-page.js +13 -0
  47. package/build/tools/create-layer.js +4 -2
  48. package/build/tools/create-page.js +8 -0
  49. package/build/tools/delete-cell-by-id.js +4 -2
  50. package/build/tools/edit-cell.js +3 -1
  51. package/build/tools/edit-edge.js +3 -1
  52. package/build/tools/export-diagram.js +7 -3
  53. package/build/tools/get-active-layer.js +4 -1
  54. package/build/tools/get-current-page.js +5 -0
  55. package/build/tools/get-selected-cell.js +4 -1
  56. package/build/tools/import-diagram.js +12 -2
  57. package/build/tools/import-mermaid.js +31 -0
  58. package/build/tools/index.js +14 -0
  59. package/build/tools/list-documents.js +17 -0
  60. package/build/tools/list-layers.js +4 -1
  61. package/build/tools/list-paged-model.js +4 -3
  62. package/build/tools/list-pages.js +5 -0
  63. package/build/tools/move-cell-to-layer.js +3 -1
  64. package/build/tools/rename-page.js +10 -0
  65. package/build/tools/set-active-layer.js +4 -2
  66. package/build/tools/set-cell-data.js +3 -1
  67. package/build/tools/set-cell-parent.js +3 -1
  68. package/build/tools/set-cell-shape.js +3 -1
  69. package/build/tools/shared.js +35 -0
  70. package/build/tools/shared.test.js +29 -0
  71. 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.4";
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
- console.log(`
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> Custom path for downloaded assets
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) => c.json({
100
- websocketPort: config.extensionPort,
101
- serverUrl: `http://localhost:${config.httpPort}`,
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
- log.debug(`Draw.io MCP Server HTTP active on port ${listeningPort}`);
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: http://localhost:${listeningPort}/mcp`);
231
+ log.debug(`MCP endpoint: ${scheme}://localhost:${listeningPort}/mcp`);
219
232
  }
220
233
  if (features.enableEditor) {
221
- log.debug(`Editor: http://localhost:${listeningPort}/`);
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(overrides) {
241
+ export function createDrawioMcpApp(options) {
242
+ const config = options?.config ?? defaultConfig();
229
243
  const emitter = new EventEmitter();
230
- const conns = new Set();
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 LOGGER_TYPE=mcp_server, in which case the
234
- // first McpServer created will be used for the logger binding.
235
- let _log = overrides?.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 (if requested)
271
- if (logger_type === "mcp_server" &&
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
- !overrides?.log) {
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 ws of [...conns]) {
292
- if (ws.readyState !== WebSocket.OPEN) {
293
- conns.delete(ws);
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 (e) {
300
- getLog().debug("[bridge] error forwarding request", e);
301
- conns.delete(ws);
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
- wsServer = new WebSocketServer({ port: extensionPort });
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
- getLog().debug(`[ws_handler] A WebSocket client #${conns.size} connected, presumably MCP Extension!`);
319
- conns.add(ws);
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
- conns.delete(ws);
333
- getLog().debug(`[ws_handler] WebSocket client closed with code ${code}`);
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
- conns.delete(ws);
630
+ flushSyncWaiters(entry);
631
+ conns.delete(connection_id);
338
632
  });
339
633
  });
340
- await new Promise((resolve, reject) => {
341
- const onListening = () => {
342
- wsServer?.off("error", onError);
343
- const address = wsServer?.address();
344
- getLog().debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
345
- resolve();
346
- };
347
- const onError = (error) => {
348
- wsServer?.off("listening", onListening);
349
- reject(error);
350
- };
351
- wsServer?.once("listening", onListening);
352
- wsServer?.once("error", onError);
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 ws of [...conns]) {
656
+ for (const entry of [...conns.values()]) {
359
657
  try {
360
- ws.close();
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(process.argv.slice(2))) {
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
- console.error(`Error: ${configResult.message}`);
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
- console.log("Initializing draw.io assets...");
767
+ app.log.debug("Initializing draw.io assets...");
433
768
  const assetConfig = {
434
769
  assetPath: config.assetPath,
435
770
  };
436
- await ensureAssets(assetConfig, (msg) => console.log(msg));
437
- console.log("Assets ready!");
771
+ await ensureAssets(assetConfig, app.log);
772
+ app.log.debug("Assets ready!");
438
773
  }
439
- const app = createDrawioMcpApp();
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.debug("Fatal error in main():", error);
787
+ fatalLog.log("error", "Fatal error in main():", error);
454
788
  process.exit(1);
455
789
  });
456
790
  }