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.
- 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 +496 -106
- package/build/install-desktop-plugin.js +56 -0
- package/build/install-desktop-plugin.test.js +66 -0
- package/build/multi-transport.test.js +277 -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 +473 -0
- package/build/real-environment/harness.js +120 -32
- 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 +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.
|
|
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,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
|
-
|
|
62
|
-
|
|
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) =>
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
210
|
+
return { app };
|
|
194
211
|
}
|
|
195
|
-
async function startHttpServer(
|
|
196
|
-
const { app
|
|
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
|
-
|
|
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:
|
|
231
|
+
log.debug(`MCP endpoint: ${scheme}://localhost:${listeningPort}/mcp`);
|
|
210
232
|
}
|
|
211
233
|
if (features.enableEditor) {
|
|
212
|
-
log.debug(`Editor:
|
|
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(
|
|
220
|
-
const
|
|
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
|
|
232
|
-
const
|
|
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
|
-
|
|
427
|
+
request_queue,
|
|
428
|
+
document_routing,
|
|
429
|
+
get log() {
|
|
430
|
+
return getLog();
|
|
431
|
+
},
|
|
238
432
|
};
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 (
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
+
getLog().debug(`[ws] failed to parse message`, error);
|
|
280
621
|
}
|
|
281
622
|
});
|
|
282
623
|
ws.on("close", (code) => {
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
629
|
+
getLog().debug(`[ws_handler] WebSocket client error`, error);
|
|
630
|
+
flushSyncWaiters(entry);
|
|
631
|
+
conns.delete(connection_id);
|
|
289
632
|
});
|
|
290
633
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
656
|
+
for (const entry of [...conns.values()]) {
|
|
310
657
|
try {
|
|
311
|
-
|
|
658
|
+
flushSyncWaiters(entry);
|
|
659
|
+
entry.ws.close();
|
|
312
660
|
}
|
|
313
661
|
catch {
|
|
314
662
|
// ignore
|
|
315
663
|
}
|
|
316
664
|
}
|
|
317
665
|
conns.clear();
|
|
318
|
-
|
|
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
|
-
|
|
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: () =>
|
|
713
|
+
startStdioTransport: async () => {
|
|
714
|
+
await start_stdio_transport(createMcpServer, getLog());
|
|
715
|
+
},
|
|
352
716
|
startHttpServer: async (httpPort, config, features) => {
|
|
353
|
-
const started = await startHttpServer(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
767
|
+
app.log.debug("Initializing draw.io assets...");
|
|
377
768
|
const assetConfig = {
|
|
378
769
|
assetPath: config.assetPath,
|
|
379
770
|
};
|
|
380
|
-
await ensureAssets(assetConfig,
|
|
381
|
-
|
|
771
|
+
await ensureAssets(assetConfig, app.log);
|
|
772
|
+
app.log.debug("Assets ready!");
|
|
382
773
|
}
|
|
383
|
-
|
|
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.
|
|
787
|
+
fatalLog.log("error", "Fatal error in main():", error);
|
|
398
788
|
process.exit(1);
|
|
399
789
|
});
|
|
400
790
|
}
|