drawio-mcp-server 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { readFileSync, existsSync, statSync, readdirSync, realpathSync, } from "node:fs";
13
13
  import { WebSocket, WebSocketServer } from "ws";
14
- const VERSION = process.env.npm_package_version ?? "2.0.0";
14
+ const VERSION = process.env.npm_package_version ?? "2.0.4";
15
15
  import { buildConfig, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
16
16
  import { bus_reply_stream, bus_request_stream, } from "./types.js";
17
17
  import { create_bus } from "./emitter_bus.js";
@@ -72,10 +72,12 @@ if (logger_type === "mcp_server") {
72
72
  },
73
73
  };
74
74
  }
75
- async function start_stdio_transport(server, log) {
75
+ async function start_stdio_transport(createServer, log) {
76
+ const server = createServer();
76
77
  const transport = new StdioServerTransport();
77
78
  await server.connect(transport);
78
79
  log.debug(`Draw.io MCP Server STDIO transport active`);
80
+ return server;
79
81
  }
80
82
  function setupCors(app) {
81
83
  app.use("*", cors({
@@ -175,28 +177,35 @@ function registerEditorRoutes(app, config, log) {
175
177
  });
176
178
  log.debug(`Draw.io editor enabled at: http://localhost:${config.httpPort}/`);
177
179
  }
178
- function registerMcpRoute(app) {
179
- const transport = new WebStandardStreamableHTTPServerTransport();
180
- app.all("/mcp", (c) => transport.handleRequest(c.req.raw));
181
- return transport;
180
+ function registerMcpRoute(app, createServer, disposeMcpServer) {
181
+ app.all("/mcp", async (c) => {
182
+ const transport = new WebStandardStreamableHTTPServerTransport();
183
+ const server = createServer();
184
+ await server.connect(transport);
185
+ // Remove the server from the tracking set immediately so it does not
186
+ // prevent garbage collection. We intentionally do *not* call
187
+ // server.close() here because the response may be a long-lived SSE
188
+ // stream (e.g. for GET requests). The transport / server will be
189
+ // collected once the response is fully consumed.
190
+ disposeMcpServer(server);
191
+ return transport.handleRequest(c.req.raw);
192
+ });
182
193
  }
183
- function createHttpApp(log, config, features) {
194
+ function createHttpApp(log, config, features, createServer, disposeMcpServer) {
184
195
  const app = new Hono();
185
196
  setupCors(app);
186
197
  if (features.enableHealth)
187
198
  registerHealthRoute(app);
188
199
  if (features.enableConfig)
189
200
  registerConfigRoute(app, config);
190
- const mcpTransport = features.enableMcp ? registerMcpRoute(app) : undefined;
201
+ if (features.enableMcp)
202
+ registerMcpRoute(app, createServer, disposeMcpServer);
191
203
  if (features.enableEditor)
192
204
  registerEditorRoutes(app, config, log);
193
- return { app, mcpTransport };
205
+ return { app };
194
206
  }
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
- }
207
+ async function startHttpServer(createServer, disposeMcpServer, log, httpPort, config, features) {
208
+ const { app } = createHttpApp(log, config, features, createServer, disposeMcpServer);
200
209
  const httpServer = serve({
201
210
  fetch: app.fetch,
202
211
  port: httpPort,
@@ -217,28 +226,68 @@ async function startHttpServer(server, log, httpPort, config, features) {
217
226
  };
218
227
  }
219
228
  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());
230
229
  const emitter = new EventEmitter();
231
230
  const conns = new Set();
232
- const bus = create_bus(log)(emitter);
231
+ const mcpServers = new Set();
232
+ // 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;
236
+ let _serverLoggerBound = false;
237
+ function getLog() {
238
+ if (_log)
239
+ return _log;
240
+ _log = create_console_logger();
241
+ return _log;
242
+ }
243
+ // Proxy logger that lazily resolves to the real logger, allowing the
244
+ // mcp_server_logger to be bound after the first McpServer is created.
245
+ const lazyLog = {
246
+ log: (level, message, ...data) => getLog().log(level, message, ...data),
247
+ debug: (message, ...data) => getLog().debug(message, ...data),
248
+ };
249
+ const bus = create_bus(lazyLog)(emitter);
233
250
  const id_generator = nanoid_id_generator();
234
251
  const context = {
235
252
  bus,
236
253
  id_generator,
237
- log,
254
+ get log() {
255
+ return getLog();
256
+ },
238
257
  };
239
- registerTools(server, context);
258
+ /**
259
+ * Factory: creates a new McpServer instance with all tools registered.
260
+ * Each transport must use its own McpServer since the MCP SDK only
261
+ * allows a single transport connection per Protocol instance.
262
+ */
263
+ function createMcpServer() {
264
+ const server = new McpServer({
265
+ name: "drawio-mcp-server",
266
+ version: VERSION,
267
+ }, {
268
+ capabilities,
269
+ });
270
+ // Bind the mcp_server logger to the first server created (if requested)
271
+ if (logger_type === "mcp_server" &&
272
+ !_serverLoggerBound &&
273
+ !overrides?.log) {
274
+ _log = create_server_logger(server);
275
+ _serverLoggerBound = true;
276
+ }
277
+ registerTools(server, context);
278
+ mcpServers.add(server);
279
+ return server;
280
+ }
281
+ /**
282
+ * Remove a previously created McpServer from the tracking set.
283
+ * Used by the HTTP route handler to prevent unbounded growth of
284
+ * the set when creating per-request servers in stateless mode.
285
+ */
286
+ function disposeMcpServer(server) {
287
+ mcpServers.delete(server);
288
+ }
240
289
  const bus_to_ws_forwarder_listener = (event) => {
241
- log.debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
290
+ getLog().debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
242
291
  for (const ws of [...conns]) {
243
292
  if (ws.readyState !== WebSocket.OPEN) {
244
293
  conns.delete(ws);
@@ -248,7 +297,7 @@ export function createDrawioMcpApp(overrides) {
248
297
  ws.send(JSON.stringify(event));
249
298
  }
250
299
  catch (e) {
251
- log.debug("[bridge] error forwarding request", e);
300
+ getLog().debug("[bridge] error forwarding request", e);
252
301
  conns.delete(ws);
253
302
  }
254
303
  }
@@ -257,7 +306,7 @@ export function createDrawioMcpApp(overrides) {
257
306
  let wsServer;
258
307
  let httpServer;
259
308
  async function startWebSocketServer(extensionPort = 3333) {
260
- log.debug(`Draw.io MCP Server (${VERSION}) starting (WebSocket extension port: ${extensionPort})`);
309
+ getLog().debug(`Draw.io MCP Server (${VERSION}) starting (WebSocket extension port: ${extensionPort})`);
261
310
  if (extensionPort !== 0) {
262
311
  const isPortAvailable = await checkPortAvailable(extensionPort);
263
312
  if (!isPortAvailable) {
@@ -266,25 +315,25 @@ export function createDrawioMcpApp(overrides) {
266
315
  }
267
316
  wsServer = new WebSocketServer({ port: extensionPort });
268
317
  wsServer.on("connection", (ws) => {
269
- log.debug(`[ws_handler] A WebSocket client #${conns.size} connected, presumably MCP Extension!`);
318
+ getLog().debug(`[ws_handler] A WebSocket client #${conns.size} connected, presumably MCP Extension!`);
270
319
  conns.add(ws);
271
320
  ws.on("message", (data) => {
272
321
  const str = typeof data === "string" ? data : data.toString();
273
322
  try {
274
323
  const json = JSON.parse(str);
275
- log.debug(`[ws] received from Extension`, json);
324
+ getLog().debug(`[ws] received from Extension`, json);
276
325
  emitter.emit(bus_reply_stream, json);
277
326
  }
278
327
  catch (error) {
279
- log.debug(`[ws] failed to parse message`, error);
328
+ getLog().debug(`[ws] failed to parse message`, error);
280
329
  }
281
330
  });
282
331
  ws.on("close", (code) => {
283
332
  conns.delete(ws);
284
- log.debug(`[ws_handler] WebSocket client closed with code ${code}`);
333
+ getLog().debug(`[ws_handler] WebSocket client closed with code ${code}`);
285
334
  });
286
335
  ws.on("error", (error) => {
287
- log.debug(`[ws_handler] WebSocket client error`, error);
336
+ getLog().debug(`[ws_handler] WebSocket client error`, error);
288
337
  conns.delete(ws);
289
338
  });
290
339
  });
@@ -292,7 +341,7 @@ export function createDrawioMcpApp(overrides) {
292
341
  const onListening = () => {
293
342
  wsServer?.off("error", onError);
294
343
  const address = wsServer?.address();
295
- log.debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
344
+ getLog().debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
296
345
  resolve();
297
346
  };
298
347
  const onError = (error) => {
@@ -315,7 +364,10 @@ export function createDrawioMcpApp(overrides) {
315
364
  }
316
365
  }
317
366
  conns.clear();
318
- await server.close();
367
+ for (const s of mcpServers) {
368
+ await s.close();
369
+ }
370
+ mcpServers.clear();
319
371
  if (wsServer) {
320
372
  await new Promise((resolve, reject) => {
321
373
  wsServer?.close((error) => {
@@ -342,15 +394,19 @@ export function createDrawioMcpApp(overrides) {
342
394
  }
343
395
  }
344
396
  return {
345
- server,
346
- log,
397
+ createMcpServer,
398
+ get log() {
399
+ return getLog();
400
+ },
347
401
  context,
348
402
  emitter,
349
403
  close,
350
404
  startWebSocketServer,
351
- startStdioTransport: () => start_stdio_transport(server, log),
405
+ startStdioTransport: async () => {
406
+ await start_stdio_transport(createMcpServer, getLog());
407
+ },
352
408
  startHttpServer: async (httpPort, config, features) => {
353
- const started = await startHttpServer(server, log, httpPort, config, features);
409
+ const started = await startHttpServer(createMcpServer, disposeMcpServer, getLog(), httpPort, config, features);
354
410
  httpServer = started.server;
355
411
  return started;
356
412
  },
@@ -0,0 +1,160 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import { createDrawioMcpApp } from "./index.js";
5
+ import { MemoryLogger } from "./real-environment/logger.js";
6
+ describe("multi-transport support", () => {
7
+ let app;
8
+ let logger;
9
+ beforeEach(() => {
10
+ logger = new MemoryLogger();
11
+ app = createDrawioMcpApp({ log: logger });
12
+ });
13
+ afterEach(async () => {
14
+ await app.close();
15
+ });
16
+ it("createMcpServer returns distinct instances", () => {
17
+ const server1 = app.createMcpServer();
18
+ const server2 = app.createMcpServer();
19
+ expect(server1).not.toBe(server2);
20
+ });
21
+ it("each McpServer instance has tools registered", async () => {
22
+ const [ct1, st1] = InMemoryTransport.createLinkedPair();
23
+ const [ct2, st2] = InMemoryTransport.createLinkedPair();
24
+ const server1 = app.createMcpServer();
25
+ const server2 = app.createMcpServer();
26
+ const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
27
+ const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
28
+ await Promise.all([
29
+ server1.connect(st1),
30
+ client1.connect(ct1),
31
+ server2.connect(st2),
32
+ client2.connect(ct2),
33
+ ]);
34
+ const tools1 = await client1.listTools();
35
+ const tools2 = await client2.listTools();
36
+ expect(tools1.tools.length).toBeGreaterThan(0);
37
+ expect(tools2.tools.length).toBeGreaterThan(0);
38
+ const names1 = tools1.tools.map((t) => t.name).sort();
39
+ const names2 = tools2.tools.map((t) => t.name).sort();
40
+ expect(names1).toEqual(names2);
41
+ await client1.close();
42
+ await client2.close();
43
+ });
44
+ it("two InMemoryTransport connections work simultaneously without 'Already connected' error", async () => {
45
+ const [ct1, st1] = InMemoryTransport.createLinkedPair();
46
+ const [ct2, st2] = InMemoryTransport.createLinkedPair();
47
+ const server1 = app.createMcpServer();
48
+ const server2 = app.createMcpServer();
49
+ const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
50
+ const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
51
+ // This is the exact scenario that was failing before the fix:
52
+ // connecting two transports should not throw.
53
+ await expect(Promise.all([
54
+ server1.connect(st1),
55
+ client1.connect(ct1),
56
+ server2.connect(st2),
57
+ client2.connect(ct2),
58
+ ])).resolves.not.toThrow();
59
+ // Both clients can independently list tools
60
+ const [tools1, tools2] = await Promise.all([
61
+ client1.listTools(),
62
+ client2.listTools(),
63
+ ]);
64
+ expect(tools1.tools.length).toBeGreaterThan(0);
65
+ expect(tools2.tools.length).toBeGreaterThan(0);
66
+ await client1.close();
67
+ await client2.close();
68
+ });
69
+ it("close() shuts down all created McpServer instances", async () => {
70
+ const [ct1, st1] = InMemoryTransport.createLinkedPair();
71
+ const [ct2, st2] = InMemoryTransport.createLinkedPair();
72
+ const server1 = app.createMcpServer();
73
+ const server2 = app.createMcpServer();
74
+ const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
75
+ const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
76
+ await Promise.all([
77
+ server1.connect(st1),
78
+ client1.connect(ct1),
79
+ server2.connect(st2),
80
+ client2.connect(ct2),
81
+ ]);
82
+ // close() should succeed without errors even with multiple servers
83
+ await expect(app.close()).resolves.not.toThrow();
84
+ // After close, listing tools should fail because the servers are shut down
85
+ await expect(client1.listTools()).rejects.toThrow();
86
+ await expect(client2.listTools()).rejects.toThrow();
87
+ });
88
+ it("connecting the same McpServer instance to two transports still throws", async () => {
89
+ const [_ct1, st1] = InMemoryTransport.createLinkedPair();
90
+ const [_ct2, st2] = InMemoryTransport.createLinkedPair();
91
+ const server = app.createMcpServer();
92
+ await server.connect(st1);
93
+ // The SDK constraint hasn't changed — a single Protocol instance
94
+ // still rejects a second connect().
95
+ await expect(server.connect(st2)).rejects.toThrow(/already connected/i);
96
+ });
97
+ });
98
+ describe("HTTP transport (stateless per-request)", () => {
99
+ let app;
100
+ let logger;
101
+ let httpServer;
102
+ let port;
103
+ const config = {
104
+ extensionPort: 0,
105
+ httpPort: 0,
106
+ transports: ["http"],
107
+ editorEnabled: false,
108
+ };
109
+ const features = {
110
+ enableMcp: true,
111
+ enableEditor: false,
112
+ enableHealth: false,
113
+ enableConfig: false,
114
+ };
115
+ beforeEach(async () => {
116
+ logger = new MemoryLogger();
117
+ app = createDrawioMcpApp({ log: logger });
118
+ const started = await app.startHttpServer(0, config, features);
119
+ httpServer = started.server;
120
+ port = started.port;
121
+ });
122
+ afterEach(async () => {
123
+ await app.close();
124
+ });
125
+ it("handles a single HTTP client request", async () => {
126
+ const client = new Client({ name: "http-test-1", version: "1.0.0" });
127
+ const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
128
+ await client.connect(transport);
129
+ const tools = await client.listTools();
130
+ expect(tools.tools.length).toBeGreaterThan(0);
131
+ await client.close();
132
+ });
133
+ it("handles multiple sequential HTTP client requests without reuse error", async () => {
134
+ // First request
135
+ const client1 = new Client({ name: "http-test-seq-1", version: "1.0.0" });
136
+ const transport1 = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
137
+ await client1.connect(transport1);
138
+ const tools1 = await client1.listTools();
139
+ expect(tools1.tools.length).toBeGreaterThan(0);
140
+ await client1.close();
141
+ // Second request — this was the exact scenario that triggered the
142
+ // "Stateless transport cannot be reused across requests" error.
143
+ const client2 = new Client({ name: "http-test-seq-2", version: "1.0.0" });
144
+ const transport2 = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
145
+ await client2.connect(transport2);
146
+ const tools2 = await client2.listTools();
147
+ expect(tools2.tools.length).toBeGreaterThan(0);
148
+ await client2.close();
149
+ });
150
+ it("close() succeeds after HTTP requests (per-request servers are cleaned up)", async () => {
151
+ const client = new Client({ name: "http-cleanup", version: "1.0.0" });
152
+ const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
153
+ await client.connect(transport);
154
+ await client.listTools();
155
+ await client.close();
156
+ // close() should not hang or throw — the per-request McpServer
157
+ // was already disposed and removed from the tracking set.
158
+ await expect(app.close()).resolves.not.toThrow();
159
+ });
160
+ });
@@ -0,0 +1,473 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createRealEnvironmentContext, disposeRealEnvironmentContext, resetDiagram, selectCell, } from "./harness.js";
6
+ import { expectNoBrowserErrors, expectNoServerErrors, withVerificationScreenshot, } from "./assertions.js";
7
+ import { callToolJson, callToolRaw } from "./tools.js";
8
+ import { expectToolSuccess } from "./test-helpers.js";
9
+ function findText(content, substring) {
10
+ return content.find((item) => item.type === "text" && item.text?.includes(substring));
11
+ }
12
+ function parseDimensions(metaText) {
13
+ const match = metaText.match(/(\d+)x(\d+)/);
14
+ return {
15
+ width: match ? parseInt(match[1], 10) : 0,
16
+ height: match ? parseInt(match[2], 10) : 0,
17
+ };
18
+ }
19
+ async function saveArtifact(artifactRunDir, name, data) {
20
+ const filePath = join(artifactRunDir, name);
21
+ await writeFile(filePath, data);
22
+ return filePath;
23
+ }
24
+ describe("real environment/export-diagram", () => {
25
+ let context;
26
+ beforeAll(async () => {
27
+ context = await createRealEnvironmentContext();
28
+ }, 180000);
29
+ afterAll(async () => {
30
+ await disposeRealEnvironmentContext(context);
31
+ });
32
+ it("exports as XML with cell content, geometry, and style preserved", async () => {
33
+ await resetDiagram(context);
34
+ context.browserMessages.length = 0;
35
+ const logCountBefore = context.logger.entries.length;
36
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
37
+ x: 100,
38
+ y: 100,
39
+ width: 200,
40
+ height: 100,
41
+ text: "XML Export Cell",
42
+ style: "fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;",
43
+ });
44
+ expectToolSuccess(rectangle);
45
+ const result = await callToolRaw(context, "export-diagram", {
46
+ format: "xml",
47
+ });
48
+ const content = result.content;
49
+ await withVerificationScreenshot(context, "export-xml", "before-live-state-verification", async () => {
50
+ const xmlContent = findText(content, "mxGraphModel");
51
+ expect(xmlContent).toBeDefined();
52
+ const xml = xmlContent.text;
53
+ expect(xml).toContain("XML Export Cell");
54
+ expect(xml).toContain(rectangle.result.id);
55
+ expect(xml).toContain("fillColor=#dae8fc");
56
+ expect(xml).toContain("strokeColor=#6c8ebf");
57
+ expect(xml).toContain('width="200"');
58
+ expect(xml).toContain('height="100"');
59
+ expect(xml).toContain('x="100"');
60
+ expect(xml).toContain('y="100"');
61
+ expect(xml).toContain("mxGeometry");
62
+ expect(xml).toContain('vertex="1"');
63
+ const metaContent = findText(content, "Exported xml");
64
+ expect(metaContent).toBeDefined();
65
+ expect(metaContent.text).toContain("application/xml");
66
+ await saveArtifact(context.artifactRunDir, "export-xml.xml", xml);
67
+ });
68
+ await expectNoBrowserErrors(context, "export-xml");
69
+ await expectNoServerErrors(context, "export-xml", logCountBefore);
70
+ }, 180000);
71
+ it("exports as SVG with renderable structure and positive dimensions", async () => {
72
+ await resetDiagram(context);
73
+ context.browserMessages.length = 0;
74
+ const logCountBefore = context.logger.entries.length;
75
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
76
+ x: 50,
77
+ y: 50,
78
+ width: 160,
79
+ height: 80,
80
+ text: "SVG Export Cell",
81
+ });
82
+ expectToolSuccess(rectangle);
83
+ const result = await callToolRaw(context, "export-diagram", {
84
+ format: "svg",
85
+ scale: 1,
86
+ border: 10,
87
+ background: "#f0f0f0",
88
+ });
89
+ const content = result.content;
90
+ await withVerificationScreenshot(context, "export-svg", "before-live-state-verification", async () => {
91
+ const svgContent = findText(content, "<svg");
92
+ expect(svgContent).toBeDefined();
93
+ const svg = svgContent.text;
94
+ expect(svg).toContain("SVG Export Cell");
95
+ expect(svg).toContain("xmlns=");
96
+ expect(svg).toMatch(/width="\d+/);
97
+ expect(svg).toMatch(/height="\d+/);
98
+ expect(svg).toContain("</svg>");
99
+ const widthMatch = svg.match(/width="(\d+)/);
100
+ const heightMatch = svg.match(/height="(\d+)/);
101
+ expect(parseInt(widthMatch[1], 10)).toBeGreaterThan(0);
102
+ expect(parseInt(heightMatch[1], 10)).toBeGreaterThan(0);
103
+ const metaContent = findText(content, "Exported svg");
104
+ expect(metaContent).toBeDefined();
105
+ expect(metaContent.text).toContain("image/svg+xml");
106
+ const dims = parseDimensions(metaContent.text);
107
+ expect(dims.width).toBeGreaterThan(0);
108
+ expect(dims.height).toBeGreaterThan(0);
109
+ await saveArtifact(context.artifactRunDir, "export-svg.svg", svg);
110
+ });
111
+ await expectNoBrowserErrors(context, "export-svg");
112
+ await expectNoServerErrors(context, "export-svg", logCountBefore);
113
+ }, 180000);
114
+ it("exports as PNG with non-trivial image data and positive dimensions", async () => {
115
+ await resetDiagram(context);
116
+ context.browserMessages.length = 0;
117
+ const logCountBefore = context.logger.entries.length;
118
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
119
+ x: 80,
120
+ y: 80,
121
+ width: 140,
122
+ height: 70,
123
+ text: "PNG Export Cell",
124
+ });
125
+ expectToolSuccess(rectangle);
126
+ const result = await callToolRaw(context, "export-diagram", {
127
+ format: "png",
128
+ scale: 2,
129
+ dpi: 150,
130
+ });
131
+ const content = result.content;
132
+ await withVerificationScreenshot(context, "export-png", "before-live-state-verification", async () => {
133
+ const imageContent = content.find((item) => item.type === "image");
134
+ expect(imageContent).toBeDefined();
135
+ expect(imageContent.mimeType).toBe("image/png");
136
+ expect(imageContent.data).toBeTruthy();
137
+ const pngBuffer = Buffer.from(imageContent.data, "base64");
138
+ expect(pngBuffer[0]).toBe(0x89);
139
+ expect(pngBuffer[1]).toBe(0x50);
140
+ expect(pngBuffer[2]).toBe(0x4e);
141
+ expect(pngBuffer[3]).toBe(0x47);
142
+ expect(pngBuffer.length).toBeGreaterThan(500);
143
+ const metaContent = content.find((item) => item.type === "text" &&
144
+ item.text?.includes("Exported png"));
145
+ expect(metaContent).toBeDefined();
146
+ expect(metaContent.text).toContain("image/png");
147
+ const dims = parseDimensions(metaContent.text);
148
+ expect(dims.width).toBeGreaterThan(0);
149
+ expect(dims.height).toBeGreaterThan(0);
150
+ await saveArtifact(context.artifactRunDir, "export-png.png", pngBuffer);
151
+ });
152
+ await expectNoBrowserErrors(context, "export-png");
153
+ await expectNoServerErrors(context, "export-png", logCountBefore);
154
+ }, 180000);
155
+ it("exports XML to a file and file matches returned content", async () => {
156
+ await resetDiagram(context);
157
+ context.browserMessages.length = 0;
158
+ const logCountBefore = context.logger.entries.length;
159
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
160
+ x: 100,
161
+ y: 100,
162
+ width: 180,
163
+ height: 90,
164
+ text: "File Export Cell",
165
+ style: "fillColor=#ffe6cc;strokeColor=#d79b00;whiteSpace=wrap;html=1;",
166
+ });
167
+ expectToolSuccess(rectangle);
168
+ const exportPath = join(context.artifactRunDir, "export-xml-file.xml");
169
+ const result = await callToolRaw(context, "export-diagram", {
170
+ format: "xml",
171
+ output_path: exportPath,
172
+ });
173
+ const content = result.content;
174
+ await withVerificationScreenshot(context, "export-xml-file", "before-live-state-verification", async () => {
175
+ expect(existsSync(exportPath)).toBe(true);
176
+ const fileContent = readFileSync(exportPath, "utf-8");
177
+ expect(fileContent).toContain("mxGraphModel");
178
+ expect(fileContent).toContain("File Export Cell");
179
+ expect(fileContent).toContain(rectangle.result.id);
180
+ expect(fileContent).toContain("fillColor=#ffe6cc");
181
+ expect(fileContent).toContain('width="180"');
182
+ expect(fileContent).toContain('height="90"');
183
+ const returnedXml = findText(content, "mxGraphModel");
184
+ expect(returnedXml).toBeDefined();
185
+ expect(fileContent).toBe(returnedXml.text);
186
+ const savedContent = findText(content, "Saved to:");
187
+ expect(savedContent).toBeDefined();
188
+ expect(savedContent.text).toContain(exportPath);
189
+ });
190
+ await expectNoBrowserErrors(context, "export-xml-file");
191
+ await expectNoServerErrors(context, "export-xml-file", logCountBefore);
192
+ }, 180000);
193
+ it("exports SVG to a file with valid SVG structure", async () => {
194
+ await resetDiagram(context);
195
+ context.browserMessages.length = 0;
196
+ const logCountBefore = context.logger.entries.length;
197
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
198
+ x: 60,
199
+ y: 60,
200
+ width: 150,
201
+ height: 80,
202
+ text: "SVG File Cell",
203
+ });
204
+ expectToolSuccess(rectangle);
205
+ const exportPath = join(context.artifactRunDir, "export-svg-file.svg");
206
+ const result = await callToolRaw(context, "export-diagram", {
207
+ format: "svg",
208
+ output_path: exportPath,
209
+ });
210
+ const content = result.content;
211
+ await withVerificationScreenshot(context, "export-svg-file", "before-live-state-verification", async () => {
212
+ expect(existsSync(exportPath)).toBe(true);
213
+ const fileContent = readFileSync(exportPath, "utf-8");
214
+ expect(fileContent).toContain("<svg");
215
+ expect(fileContent).toContain("</svg>");
216
+ expect(fileContent).toContain("SVG File Cell");
217
+ expect(fileContent).toContain("xmlns=");
218
+ expect(fileContent).toMatch(/width="\d+/);
219
+ expect(fileContent).toMatch(/height="\d+/);
220
+ const returnedSvg = findText(content, "<svg");
221
+ expect(returnedSvg).toBeDefined();
222
+ expect(fileContent).toBe(returnedSvg.text);
223
+ const savedContent = findText(content, "Saved to:");
224
+ expect(savedContent).toBeDefined();
225
+ });
226
+ await expectNoBrowserErrors(context, "export-svg-file");
227
+ await expectNoServerErrors(context, "export-svg-file", logCountBefore);
228
+ }, 180000);
229
+ it("exports PNG to a file with valid PNG binary data", async () => {
230
+ await resetDiagram(context);
231
+ context.browserMessages.length = 0;
232
+ const logCountBefore = context.logger.entries.length;
233
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
234
+ x: 70,
235
+ y: 70,
236
+ width: 130,
237
+ height: 65,
238
+ text: "PNG File Cell",
239
+ });
240
+ expectToolSuccess(rectangle);
241
+ const exportPath = join(context.artifactRunDir, "export-png-file.png");
242
+ const result = await callToolRaw(context, "export-diagram", {
243
+ format: "png",
244
+ output_path: exportPath,
245
+ });
246
+ const content = result.content;
247
+ await withVerificationScreenshot(context, "export-png-file", "before-live-state-verification", async () => {
248
+ expect(existsSync(exportPath)).toBe(true);
249
+ const fileBuffer = readFileSync(exportPath);
250
+ expect(fileBuffer[0]).toBe(0x89);
251
+ expect(fileBuffer[1]).toBe(0x50);
252
+ expect(fileBuffer[2]).toBe(0x4e);
253
+ expect(fileBuffer[3]).toBe(0x47);
254
+ expect(fileBuffer.length).toBeGreaterThan(500);
255
+ const imageContent = content.find((item) => item.type === "image");
256
+ expect(imageContent).toBeDefined();
257
+ const returnedBuffer = Buffer.from(imageContent.data, "base64");
258
+ expect(fileBuffer.equals(returnedBuffer)).toBe(true);
259
+ const savedContent = content.find((item) => item.type === "text" &&
260
+ item.text?.includes("Saved to:"));
261
+ expect(savedContent).toBeDefined();
262
+ });
263
+ await expectNoBrowserErrors(context, "export-png-file");
264
+ await expectNoServerErrors(context, "export-png-file", logCountBefore);
265
+ }, 180000);
266
+ it("exports with transparent background and SVG has no background rect", async () => {
267
+ await resetDiagram(context);
268
+ context.browserMessages.length = 0;
269
+ const logCountBefore = context.logger.entries.length;
270
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
271
+ x: 100,
272
+ y: 100,
273
+ width: 120,
274
+ height: 60,
275
+ text: "Transparent BG",
276
+ });
277
+ expectToolSuccess(rectangle);
278
+ const opaqueResult = await callToolRaw(context, "export-diagram", {
279
+ format: "svg",
280
+ transparent: false,
281
+ background: "#ff0000",
282
+ });
283
+ const opaqueContent = opaqueResult.content;
284
+ const opaqueSvg = findText(opaqueContent, "<svg");
285
+ const transparentResult = await callToolRaw(context, "export-diagram", {
286
+ format: "svg",
287
+ transparent: true,
288
+ });
289
+ const transparentContent = transparentResult.content;
290
+ await withVerificationScreenshot(context, "export-transparent", "before-live-state-verification", async () => {
291
+ const svgContent = findText(transparentContent, "<svg");
292
+ expect(svgContent).toBeDefined();
293
+ expect(svgContent.text).toContain("Transparent BG");
294
+ expect(svgContent.text).toContain("</svg>");
295
+ expect(svgContent.text).not.toContain("#ff0000");
296
+ expect(opaqueSvg).toBeDefined();
297
+ expect(opaqueSvg.text).toContain("#ff0000");
298
+ const metaContent = findText(transparentContent, "Exported svg");
299
+ expect(metaContent).toBeDefined();
300
+ await saveArtifact(context.artifactRunDir, "export-transparent-opaque.svg", opaqueSvg.text);
301
+ await saveArtifact(context.artifactRunDir, "export-transparent-transparent.svg", svgContent.text);
302
+ });
303
+ await expectNoBrowserErrors(context, "export-transparent");
304
+ await expectNoServerErrors(context, "export-transparent", logCountBefore);
305
+ }, 180000);
306
+ it("exports SVG with embedded XML containing diagram data", async () => {
307
+ await resetDiagram(context);
308
+ context.browserMessages.length = 0;
309
+ const logCountBefore = context.logger.entries.length;
310
+ const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
311
+ x: 90,
312
+ y: 90,
313
+ width: 150,
314
+ height: 75,
315
+ text: "Embed XML Cell",
316
+ });
317
+ expectToolSuccess(rectangle);
318
+ const plainResult = await callToolRaw(context, "export-diagram", {
319
+ format: "svg",
320
+ embed_xml: false,
321
+ });
322
+ const plainContent = plainResult.content;
323
+ const embeddedResult = await callToolRaw(context, "export-diagram", {
324
+ format: "svg",
325
+ embed_xml: true,
326
+ });
327
+ const embeddedContent = embeddedResult.content;
328
+ await withVerificationScreenshot(context, "export-svg-embed-xml", "before-live-state-verification", async () => {
329
+ const embeddedSvg = findText(embeddedContent, "Embed XML Cell");
330
+ expect(embeddedSvg).toBeDefined();
331
+ const embeddedData = embeddedSvg.text;
332
+ expect(embeddedData).toContain("mxGraphModel");
333
+ expect(embeddedData).toContain("mxfile");
334
+ const plainSvg = findText(plainContent, "<svg");
335
+ expect(plainSvg).toBeDefined();
336
+ expect(plainSvg.text).not.toContain("mxfile");
337
+ expect(embeddedData.length).toBeGreaterThan(plainSvg.text.length);
338
+ const metaContent = findText(embeddedContent, "Exported svg");
339
+ expect(metaContent).toBeDefined();
340
+ expect(metaContent.text).toContain("image/svg+xml");
341
+ await saveArtifact(context.artifactRunDir, "export-svg-plain.svg", plainSvg.text);
342
+ await saveArtifact(context.artifactRunDir, "export-svg-embedded.svg", embeddedData);
343
+ });
344
+ await expectNoBrowserErrors(context, "export-svg-embed-xml");
345
+ await expectNoServerErrors(context, "export-svg-embed-xml", logCountBefore);
346
+ }, 180000);
347
+ it("exports only the selected cell in SVG and excludes unselected cells", async () => {
348
+ await resetDiagram(context);
349
+ context.browserMessages.length = 0;
350
+ const logCountBefore = context.logger.entries.length;
351
+ const { payload: selectedRect } = await callToolJson(context, "add-rectangle", {
352
+ x: 100,
353
+ y: 100,
354
+ width: 140,
355
+ height: 70,
356
+ text: "Selected Cell",
357
+ });
358
+ expectToolSuccess(selectedRect);
359
+ const { payload: otherRect } = await callToolJson(context, "add-rectangle", {
360
+ x: 350,
361
+ y: 100,
362
+ width: 140,
363
+ height: 70,
364
+ text: "Unselected Cell",
365
+ });
366
+ expectToolSuccess(otherRect);
367
+ await selectCell(context.page, selectedRect.result.id);
368
+ // SVG export respects selection_only by rendering only the selected cells
369
+ const selectionResult = await callToolRaw(context, "export-diagram", {
370
+ format: "svg",
371
+ selection_only: true,
372
+ size: "selection",
373
+ });
374
+ const selectionContent = selectionResult.content;
375
+ const fullResult = await callToolRaw(context, "export-diagram", {
376
+ format: "svg",
377
+ });
378
+ const fullContent = fullResult.content;
379
+ await withVerificationScreenshot(context, "export-selection-only", "before-live-state-verification", async () => {
380
+ const selectionSvg = findText(selectionContent, "<svg");
381
+ expect(selectionSvg).toBeDefined();
382
+ expect(selectionSvg.text).toContain("Selected Cell");
383
+ expect(selectionSvg.text).not.toContain("Unselected Cell");
384
+ const fullSvg = findText(fullContent, "<svg");
385
+ expect(fullSvg).toBeDefined();
386
+ expect(fullSvg.text).toContain("Selected Cell");
387
+ expect(fullSvg.text).toContain("Unselected Cell");
388
+ await saveArtifact(context.artifactRunDir, "export-selection-only.svg", selectionSvg.text);
389
+ await saveArtifact(context.artifactRunDir, "export-selection-full.svg", fullSvg.text);
390
+ });
391
+ await expectNoBrowserErrors(context, "export-selection-only");
392
+ await expectNoServerErrors(context, "export-selection-only", logCountBefore);
393
+ }, 180000);
394
+ it("exports multiple cells and all appear in the output", async () => {
395
+ await resetDiagram(context);
396
+ context.browserMessages.length = 0;
397
+ const logCountBefore = context.logger.entries.length;
398
+ const cellNames = ["Alpha", "Beta", "Gamma"];
399
+ const cellIds = [];
400
+ for (let i = 0; i < cellNames.length; i++) {
401
+ const { payload } = await callToolJson(context, "add-rectangle", {
402
+ x: 50 + i * 200,
403
+ y: 100,
404
+ width: 140,
405
+ height: 70,
406
+ text: cellNames[i],
407
+ });
408
+ expectToolSuccess(payload);
409
+ cellIds.push(payload.result.id);
410
+ }
411
+ const xmlResult = await callToolRaw(context, "export-diagram", {
412
+ format: "xml",
413
+ });
414
+ const xmlContent = xmlResult.content;
415
+ const svgResult = await callToolRaw(context, "export-diagram", {
416
+ format: "svg",
417
+ });
418
+ const svgContent = svgResult.content;
419
+ const pngResult = await callToolRaw(context, "export-diagram", {
420
+ format: "png",
421
+ });
422
+ const pngContent = pngResult.content;
423
+ await withVerificationScreenshot(context, "export-multi-cell", "before-live-state-verification", async () => {
424
+ const xml = findText(xmlContent, "mxGraphModel");
425
+ expect(xml).toBeDefined();
426
+ for (const name of cellNames) {
427
+ expect(xml.text).toContain(name);
428
+ }
429
+ for (const id of cellIds) {
430
+ expect(xml.text).toContain(id);
431
+ }
432
+ const vertexMatches = xml.text.match(/vertex="1"/g);
433
+ expect(vertexMatches).not.toBeNull();
434
+ expect(vertexMatches.length).toBeGreaterThanOrEqual(cellNames.length);
435
+ const svg = findText(svgContent, "<svg");
436
+ expect(svg).toBeDefined();
437
+ for (const name of cellNames) {
438
+ expect(svg.text).toContain(name);
439
+ }
440
+ await saveArtifact(context.artifactRunDir, "export-multi-cell.xml", xml.text);
441
+ await saveArtifact(context.artifactRunDir, "export-multi-cell.svg", svg.text);
442
+ const pngImage = pngContent.find((item) => item.type === "image");
443
+ expect(pngImage).toBeDefined();
444
+ await saveArtifact(context.artifactRunDir, "export-multi-cell.png", Buffer.from(pngImage.data, "base64"));
445
+ });
446
+ await expectNoBrowserErrors(context, "export-multi-cell");
447
+ await expectNoServerErrors(context, "export-multi-cell", logCountBefore);
448
+ }, 180000);
449
+ it("exports an empty diagram with only root cells", async () => {
450
+ await resetDiagram(context);
451
+ context.browserMessages.length = 0;
452
+ const logCountBefore = context.logger.entries.length;
453
+ const result = await callToolRaw(context, "export-diagram", {
454
+ format: "xml",
455
+ });
456
+ const content = result.content;
457
+ await withVerificationScreenshot(context, "export-empty", "before-live-state-verification", async () => {
458
+ const xmlContent = findText(content, "mxGraphModel");
459
+ expect(xmlContent).toBeDefined();
460
+ const xml = xmlContent.text;
461
+ expect(xml).toContain("mxGraphModel");
462
+ expect(xml).toContain('<mxCell id="0"');
463
+ expect(xml).toContain('<mxCell id="1"');
464
+ expect(xml).not.toMatch(/vertex="1"/);
465
+ const metaContent = findText(content, "Exported xml");
466
+ expect(metaContent).toBeDefined();
467
+ expect(metaContent.text).toContain("application/xml");
468
+ await saveArtifact(context.artifactRunDir, "export-empty.xml", xml);
469
+ });
470
+ await expectNoBrowserErrors(context, "export-empty");
471
+ await expectNoServerErrors(context, "export-empty", logCountBefore);
472
+ }, 180000);
473
+ });
@@ -17,7 +17,7 @@ export async function createRealEnvironmentContext() {
17
17
  const config = {
18
18
  extensionPort: wsPort,
19
19
  httpPort: 0,
20
- transports: ["http"],
20
+ transports: [],
21
21
  editorEnabled: true,
22
22
  };
23
23
  const startedHttp = await app.startHttpServer(0, config, getHttpFeatureConfig(config));
@@ -27,8 +27,9 @@ export async function createRealEnvironmentContext() {
27
27
  name: "real-environment-test",
28
28
  version: "1.0.0",
29
29
  });
30
+ const testServer = app.createMcpServer();
30
31
  await Promise.all([
31
- app.server.connect(serverTransport),
32
+ testServer.connect(serverTransport),
32
33
  client.connect(clientTransport),
33
34
  ]);
34
35
  const browser = await chromium.launch({ headless: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drawio-mcp-server",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Provides Draw.io services to MCP Clients",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -31,31 +31,31 @@
31
31
  "url": "git+https://github.com/lgazo/drawio-mcp-server.git"
32
32
  },
33
33
  "dependencies": {
34
- "@hono/node-server": "1.19.7",
35
- "@modelcontextprotocol/sdk": "1.25.1",
36
- "cachedir": "^2.4.0",
37
- "hono": "4.11.1",
34
+ "@hono/node-server": "1.19.13",
35
+ "@modelcontextprotocol/sdk": "1.29.0",
36
+ "cachedir": "2.4.0",
37
+ "hono": "4.12.12",
38
38
  "nanoid": "5.1.6",
39
- "unzipper": "^0.12.0",
39
+ "unzipper": "0.12.3",
40
40
  "ws": "8.18.3",
41
41
  "zod": "4.2.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@jest/globals": "30.2.0",
45
- "@playwright/test": "1.54.2",
45
+ "@playwright/test": "1.59.1",
46
46
  "@types/jest": "30.0.0",
47
47
  "@types/node": "25.0.3",
48
- "@types/unzipper": "^0.10.11",
49
- "@types/ws": "^8.18.1",
50
- "esbuild": "^0.24.0",
48
+ "@types/unzipper": "0.10.11",
49
+ "@types/ws": "8.18.1",
50
+ "esbuild": "0.25.12",
51
51
  "globals": "16.5.0",
52
52
  "jest": "30.2.0",
53
53
  "jest-environment-node": "30.2.0",
54
54
  "prettier": "3.7.4",
55
- "rimraf": "6.1.2",
56
- "ts-jest": "29.4.6",
55
+ "rimraf": "6.1.3",
56
+ "ts-jest": "29.4.9",
57
57
  "typescript": "5.9.3",
58
- "drawio-mcp-plugin": "2.0.0"
58
+ "drawio-mcp-plugin": "2.0.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "rimraf build && tsc && mkdir -p build/plugin && cp node_modules/drawio-mcp-plugin/dist/mcp-plugin.js build/plugin/mcp-plugin.js",