drawio-mcp-server 2.0.3 → 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 +496 -106
  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 +277 -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 +473 -0
  16. package/build/real-environment/harness.js +120 -32
  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 +22 -14
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.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
- 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,37 +59,27 @@ 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
- async function start_stdio_transport(server, log) {
77
+ async function start_stdio_transport(createServer, log) {
78
+ const server = createServer();
76
79
  const transport = new StdioServerTransport();
77
80
  await server.connect(transport);
78
81
  log.debug(`Draw.io MCP Server STDIO transport active`);
82
+ return server;
79
83
  }
80
84
  function setupCors(app) {
81
85
  app.use("*", cors({
@@ -93,11 +97,13 @@ function setupCors(app) {
93
97
  function registerHealthRoute(app) {
94
98
  app.get("/health", (c) => c.json({ status: "ok" }));
95
99
  }
96
- function registerConfigRoute(app, config) {
97
- app.get("/api/config", (c) => c.json({
98
- websocketPort: config.extensionPort,
99
- serverUrl: `http://localhost:${config.httpPort}`,
100
- }));
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
+ });
101
107
  }
102
108
  function registerEditorRoutes(app, config, log) {
103
109
  const assetConfig = {
@@ -175,147 +181,492 @@ function registerEditorRoutes(app, config, log) {
175
181
  });
176
182
  log.debug(`Draw.io editor enabled at: http://localhost:${config.httpPort}/`);
177
183
  }
178
- function registerMcpRoute(app) {
179
- const transport = new WebStandardStreamableHTTPServerTransport();
180
- app.all("/mcp", (c) => transport.handleRequest(c.req.raw));
181
- return transport;
184
+ function registerMcpRoute(app, createServer, disposeMcpServer) {
185
+ app.all("/mcp", async (c) => {
186
+ const transport = new WebStandardStreamableHTTPServerTransport();
187
+ const server = createServer();
188
+ await server.connect(transport);
189
+ // Remove the server from the tracking set immediately so it does not
190
+ // prevent garbage collection. We intentionally do *not* call
191
+ // server.close() here because the response may be a long-lived SSE
192
+ // stream (e.g. for GET requests). The transport / server will be
193
+ // collected once the response is fully consumed.
194
+ disposeMcpServer(server);
195
+ return transport.handleRequest(c.req.raw);
196
+ });
182
197
  }
183
- function createHttpApp(log, config, features) {
198
+ function createHttpApp(log, config, features, createServer, disposeMcpServer, tlsMaterial) {
184
199
  const app = new Hono();
185
200
  setupCors(app);
201
+ const scheme = tlsMaterial ? "https" : "http";
186
202
  if (features.enableHealth)
187
203
  registerHealthRoute(app);
188
204
  if (features.enableConfig)
189
- registerConfigRoute(app, config);
190
- const mcpTransport = features.enableMcp ? registerMcpRoute(app) : undefined;
205
+ registerConfigRoute(app, config, scheme);
206
+ if (features.enableMcp)
207
+ registerMcpRoute(app, createServer, disposeMcpServer);
191
208
  if (features.enableEditor)
192
209
  registerEditorRoutes(app, config, log);
193
- return { app, mcpTransport };
210
+ return { app };
194
211
  }
195
- async function startHttpServer(server, log, httpPort, config, features) {
196
- const { app, mcpTransport } = createHttpApp(log, config, features);
197
- if (mcpTransport) {
198
- await server.connect(mcpTransport);
199
- }
212
+ async function startHttpServer(createServer, disposeMcpServer, log, httpPort, config, features, tlsMaterial) {
213
+ const { app } = createHttpApp(log, config, features, createServer, disposeMcpServer, tlsMaterial);
200
214
  const httpServer = serve({
201
215
  fetch: app.fetch,
202
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
+ : {}),
203
224
  });
204
225
  const listeningPort = httpPort === 0
205
226
  ? (httpServer.address()?.port ?? httpPort)
206
227
  : httpPort;
207
- 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})`);
208
230
  if (features.enableMcp) {
209
- log.debug(`MCP endpoint: http://localhost:${listeningPort}/mcp`);
231
+ log.debug(`MCP endpoint: ${scheme}://localhost:${listeningPort}/mcp`);
210
232
  }
211
233
  if (features.enableEditor) {
212
- log.debug(`Editor: http://localhost:${listeningPort}/`);
234
+ log.debug(`Editor: ${scheme}://localhost:${listeningPort}/`);
213
235
  }
214
236
  return {
215
237
  server: httpServer,
216
238
  port: listeningPort,
217
239
  };
218
240
  }
219
- export function createDrawioMcpApp(overrides) {
220
- const server = new McpServer({
221
- name: "drawio-mcp-server",
222
- version: VERSION,
223
- }, {
224
- capabilities,
225
- });
226
- const log = overrides?.log ??
227
- (logger_type === "mcp_server"
228
- ? create_server_logger(server)
229
- : create_console_logger());
241
+ export function createDrawioMcpApp(options) {
242
+ const config = options?.config ?? defaultConfig();
230
243
  const emitter = new EventEmitter();
231
- const conns = new Set();
232
- const bus = create_bus(log)(emitter);
244
+ const connectionIdGenerator = nanoid_id_generator();
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;
258
+ // Lazily resolved log — uses console logger until a server logger is
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;
262
+ let _serverLoggerBound = false;
263
+ function getLog() {
264
+ if (_log)
265
+ return _log;
266
+ _log = create_console_logger();
267
+ return _log;
268
+ }
269
+ // Proxy logger that lazily resolves to the real logger, allowing the
270
+ // mcp_server_logger to be bound after the first McpServer is created.
271
+ const lazyLog = {
272
+ log: (level, message, ...data) => getLog().log(level, message, ...data),
273
+ debug: (message, ...data) => getLog().debug(message, ...data),
274
+ };
275
+ const bus = create_bus(lazyLog)(emitter);
233
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
+ };
234
424
  const context = {
235
425
  bus,
236
426
  id_generator,
237
- log,
427
+ request_queue,
428
+ document_routing,
429
+ get log() {
430
+ return getLog();
431
+ },
238
432
  };
239
- registerTools(server, context);
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
+ }
451
+ /**
452
+ * Factory: creates a new McpServer instance with all tools registered.
453
+ * Each transport must use its own McpServer since the MCP SDK only
454
+ * allows a single transport connection per Protocol instance.
455
+ */
456
+ function createMcpServer() {
457
+ const server = new McpServer({
458
+ name: "drawio-mcp-server",
459
+ version: VERSION,
460
+ }, {
461
+ capabilities,
462
+ });
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" &&
466
+ !_serverLoggerBound &&
467
+ !options?.log) {
468
+ _log = create_server_logger(server);
469
+ _serverLoggerBound = true;
470
+ }
471
+ registerTools(createDocumentScopedServer(createServerWithSchemaStripping(server)), context);
472
+ mcpServers.add(server);
473
+ return server;
474
+ }
475
+ /**
476
+ * Remove a previously created McpServer from the tracking set.
477
+ * Used by the HTTP route handler to prevent unbounded growth of
478
+ * the set when creating per-request servers in stateless mode.
479
+ */
480
+ function disposeMcpServer(server) {
481
+ mcpServers.delete(server);
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
+ }
240
498
  const bus_to_ws_forwarder_listener = (event) => {
241
- log.debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
242
- for (const ws of [...conns]) {
243
- if (ws.readyState !== WebSocket.OPEN) {
244
- conns.delete(ws);
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
+ }
525
+ getLog().debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
526
+ for (const [connectionId, entry] of [...conns.entries()]) {
527
+ if (entry.ws.readyState !== WebSocket.OPEN) {
528
+ flushSyncWaiters(entry);
529
+ conns.delete(connectionId);
245
530
  continue;
246
531
  }
247
532
  try {
248
- ws.send(JSON.stringify(event));
533
+ entry.ws.send(JSON.stringify(event));
249
534
  }
250
- catch (e) {
251
- log.debug("[bridge] error forwarding request", e);
252
- conns.delete(ws);
535
+ catch (error) {
536
+ getLog().debug("[bridge] error forwarding request", error);
537
+ flushSyncWaiters(entry);
538
+ conns.delete(connectionId);
253
539
  }
254
540
  }
255
541
  };
256
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
+ });
257
554
  let wsServer;
555
+ let wssHttpsServer;
258
556
  let httpServer;
259
- async function startWebSocketServer(extensionPort = 3333) {
260
- log.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})`);
261
559
  if (extensionPort !== 0) {
262
- const isPortAvailable = await checkPortAvailable(extensionPort);
560
+ const isPortAvailable = await checkPortAvailable(extensionPort, host);
263
561
  if (!isPortAvailable) {
264
562
  throw new Error(`[start_websocket_server] Error: Port ${extensionPort} is already in use. Please stop the process using this port and try again.`);
265
563
  }
266
564
  }
267
- 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
+ }
268
594
  wsServer.on("connection", (ws) => {
269
- log.debug(`[ws_handler] A WebSocket client #${conns.size} connected, presumably MCP Extension!`);
270
- 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");
271
606
  ws.on("message", (data) => {
272
607
  const str = typeof data === "string" ? data : data.toString();
273
608
  try {
274
609
  const json = JSON.parse(str);
275
- log.debug(`[ws] received from Extension`, json);
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
+ }
276
617
  emitter.emit(bus_reply_stream, json);
277
618
  }
278
619
  catch (error) {
279
- log.debug(`[ws] failed to parse message`, error);
620
+ getLog().debug(`[ws] failed to parse message`, error);
280
621
  }
281
622
  });
282
623
  ws.on("close", (code) => {
283
- conns.delete(ws);
284
- log.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}`);
285
627
  });
286
628
  ws.on("error", (error) => {
287
- log.debug(`[ws_handler] WebSocket client error`, error);
288
- conns.delete(ws);
629
+ getLog().debug(`[ws_handler] WebSocket client error`, error);
630
+ flushSyncWaiters(entry);
631
+ conns.delete(connection_id);
289
632
  });
290
633
  });
291
- await new Promise((resolve, reject) => {
292
- const onListening = () => {
293
- wsServer?.off("error", onError);
294
- const address = wsServer?.address();
295
- log.debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
296
- resolve();
297
- };
298
- const onError = (error) => {
299
- wsServer?.off("listening", onListening);
300
- reject(error);
301
- };
302
- wsServer?.once("listening", onListening);
303
- wsServer?.once("error", onError);
304
- });
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
305
652
  return wsServer;
306
653
  }
307
654
  async function close() {
308
655
  emitter.off(bus_request_stream, bus_to_ws_forwarder_listener);
309
- for (const ws of [...conns]) {
656
+ for (const entry of [...conns.values()]) {
310
657
  try {
311
- ws.close();
658
+ flushSyncWaiters(entry);
659
+ entry.ws.close();
312
660
  }
313
661
  catch {
314
662
  // ignore
315
663
  }
316
664
  }
317
665
  conns.clear();
318
- await server.close();
666
+ for (const s of mcpServers) {
667
+ await s.close();
668
+ }
669
+ mcpServers.clear();
319
670
  if (wsServer) {
320
671
  await new Promise((resolve, reject) => {
321
672
  wsServer?.close((error) => {
@@ -328,6 +679,15 @@ export function createDrawioMcpApp(overrides) {
328
679
  });
329
680
  wsServer = undefined;
330
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
+ }
331
691
  if (httpServer) {
332
692
  await new Promise((resolve, reject) => {
333
693
  httpServer?.close((error) => {
@@ -342,46 +702,76 @@ export function createDrawioMcpApp(overrides) {
342
702
  }
343
703
  }
344
704
  return {
345
- server,
346
- log,
705
+ createMcpServer,
706
+ get log() {
707
+ return getLog();
708
+ },
347
709
  context,
348
710
  emitter,
349
711
  close,
350
712
  startWebSocketServer,
351
- startStdioTransport: () => start_stdio_transport(server, log),
713
+ startStdioTransport: async () => {
714
+ await start_stdio_transport(createMcpServer, getLog());
715
+ },
352
716
  startHttpServer: async (httpPort, config, features) => {
353
- const started = await startHttpServer(server, log, httpPort, config, features);
717
+ const started = await startHttpServer(createMcpServer, disposeMcpServer, getLog(), httpPort, config, features, tlsMaterial);
354
718
  httpServer = started.server;
355
719
  return started;
356
720
  },
357
721
  };
358
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
+ }
359
743
  async function main() {
744
+ const cliArgs = process.argv.slice(2);
360
745
  // Check if help was requested (before parsing config)
361
- if (shouldShowHelp(process.argv.slice(2))) {
746
+ if (shouldShowHelp(cliArgs)) {
362
747
  showHelp();
363
748
  // never returns
364
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
+ }
365
755
  // Build configuration from command line args
366
756
  const configResult = buildConfig();
367
757
  // Handle errors from configuration parsing
368
758
  if (configResult instanceof Error) {
369
- console.error(`Error: ${configResult.message}`);
759
+ fatalLog.log("error", `Error: ${configResult.message}`);
370
760
  process.exit(1);
371
761
  }
372
762
  const config = configResult;
373
763
  const features = getHttpFeatureConfig(config);
764
+ const app = createDrawioMcpApp({ config });
374
765
  // Initialize assets if needed
375
766
  if (features.enableEditor) {
376
- console.log("Initializing draw.io assets...");
767
+ app.log.debug("Initializing draw.io assets...");
377
768
  const assetConfig = {
378
769
  assetPath: config.assetPath,
379
770
  };
380
- await ensureAssets(assetConfig, (msg) => console.log(msg));
381
- console.log("Assets ready!");
771
+ await ensureAssets(assetConfig, app.log);
772
+ app.log.debug("Assets ready!");
382
773
  }
383
- const app = createDrawioMcpApp();
384
- await app.startWebSocketServer(config.extensionPort);
774
+ await app.startWebSocketServer(config.extensionPort, config.host);
385
775
  if (config.transports.indexOf("stdio") > -1) {
386
776
  await app.startStdioTransport();
387
777
  }
@@ -394,7 +784,7 @@ const isMainModule = process.argv[1]
394
784
  : false;
395
785
  if (isMainModule) {
396
786
  main().catch((error) => {
397
- fatalLog.debug("Fatal error in main():", error);
787
+ fatalLog.log("error", "Fatal error in main():", error);
398
788
  process.exit(1);
399
789
  });
400
790
  }