drawio-mcp-server 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +29 -3
  2. package/build/assets/downloader.js +16 -17
  3. package/build/config.js +253 -5
  4. package/build/config.test.js +449 -1
  5. package/build/emitter_bus.js +3 -0
  6. package/build/emitter_bus.test.js +7 -0
  7. package/build/index.capabilities.test.js +45 -0
  8. package/build/index.js +416 -82
  9. package/build/install-desktop-plugin.js +56 -0
  10. package/build/install-desktop-plugin.test.js +66 -0
  11. package/build/multi-transport.test.js +117 -0
  12. package/build/plugin/mcp-plugin.js +2180 -1105
  13. package/build/prefetch-assets.js +6 -5
  14. package/build/real-environment/document-targeting.test.js +124 -0
  15. package/build/real-environment/export-diagram.test.js +1 -1
  16. package/build/real-environment/harness.js +118 -31
  17. package/build/real-environment/import-mermaid.test.js +81 -0
  18. package/build/real-environment/pages-and-concurrency.test.js +448 -0
  19. package/build/real-environment/shapes.test.js +58 -0
  20. package/build/real-environment/tools.js +51 -6
  21. package/build/register-tool.js +31 -0
  22. package/build/request_queue.js +29 -0
  23. package/build/request_queue.test.js +98 -0
  24. package/build/stdio-transport-purity.test.js +88 -0
  25. package/build/strip-schema.js +61 -0
  26. package/build/tls/expiry.js +14 -0
  27. package/build/tls/expiry.test.js +54 -0
  28. package/build/tls/generate.js +123 -0
  29. package/build/tls/generate.test.js +115 -0
  30. package/build/tls/index.js +80 -0
  31. package/build/tls/index.test.js +141 -0
  32. package/build/tls/install-hint.js +45 -0
  33. package/build/tls/install-hint.test.js +32 -0
  34. package/build/tls/load.js +18 -0
  35. package/build/tls/load.test.js +40 -0
  36. package/build/tls/paths.js +30 -0
  37. package/build/tls/paths.test.js +53 -0
  38. package/build/tls/san.js +27 -0
  39. package/build/tls/san.test.js +72 -0
  40. package/build/tool-registry.test.js +823 -0
  41. package/build/tool.js +74 -15
  42. package/build/tool.test.js +250 -7
  43. package/build/tools/add-cell-of-shape.js +4 -2
  44. package/build/tools/add-edge.js +3 -1
  45. package/build/tools/add-rectangle.js +4 -2
  46. package/build/tools/copy-page.js +13 -0
  47. package/build/tools/create-layer.js +4 -2
  48. package/build/tools/create-page.js +8 -0
  49. package/build/tools/delete-cell-by-id.js +4 -2
  50. package/build/tools/edit-cell.js +3 -1
  51. package/build/tools/edit-edge.js +3 -1
  52. package/build/tools/export-diagram.js +7 -3
  53. package/build/tools/get-active-layer.js +4 -1
  54. package/build/tools/get-current-page.js +5 -0
  55. package/build/tools/get-selected-cell.js +4 -1
  56. package/build/tools/import-diagram.js +12 -2
  57. package/build/tools/import-mermaid.js +31 -0
  58. package/build/tools/index.js +14 -0
  59. package/build/tools/list-documents.js +17 -0
  60. package/build/tools/list-layers.js +4 -1
  61. package/build/tools/list-paged-model.js +4 -3
  62. package/build/tools/list-pages.js +5 -0
  63. package/build/tools/move-cell-to-layer.js +3 -1
  64. package/build/tools/rename-page.js +10 -0
  65. package/build/tools/set-active-layer.js +4 -2
  66. package/build/tools/set-cell-data.js +3 -1
  67. package/build/tools/set-cell-parent.js +3 -1
  68. package/build/tools/set-cell-shape.js +3 -1
  69. package/build/tools/shared.js +35 -0
  70. package/build/tools/shared.test.js +29 -0
  71. package/package.json +12 -4
@@ -1,11 +1,12 @@
1
1
  import { ensureAssets } from "./assets/index.js";
2
+ import { create_logger } from "./mcp_console_logger.js";
2
3
  async function main() {
3
- const { assetRoot } = await ensureAssets({}, (message) => {
4
- console.log(message);
5
- });
6
- console.log(`Assets ready at ${assetRoot}`);
4
+ const log = create_logger();
5
+ const { assetRoot } = await ensureAssets({}, log);
6
+ log.log("info", `Assets ready at ${assetRoot}`);
7
7
  }
8
8
  main().catch((error) => {
9
- console.error("Failed to prefetch draw.io assets", error);
9
+ const log = create_logger();
10
+ log.log("error", "Failed to prefetch draw.io assets", error);
10
11
  process.exit(1);
11
12
  });
@@ -0,0 +1,124 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
2
+ import { createRealEnvironmentContext, disposeRealEnvironmentContext, openConnectedDrawioPage, resetDiagram, } from "./harness.js";
3
+ import { expectNoBrowserErrors, expectNoServerErrors } from "./assertions.js";
4
+ import { callToolJson } from "./tools.js";
5
+ import { expectToolSuccess, unwrapToolPayload } from "./test-helpers.js";
6
+ function firstText(result) {
7
+ return result.content.find((item) => item.type === "text")?.text;
8
+ }
9
+ describe("real environment/document targeting", () => {
10
+ let context;
11
+ beforeAll(async () => {
12
+ context = await createRealEnvironmentContext();
13
+ }, 180000);
14
+ afterAll(async () => {
15
+ await disposeRealEnvironmentContext(context);
16
+ });
17
+ it("lists connected documents and requires target_document when multiple tabs are connected", async () => {
18
+ await resetDiagram(context);
19
+ context.browserMessages.length = 0;
20
+ const logCountBefore = context.logger.entries.length;
21
+ const { payload: renamedDocOnePayload } = await callToolJson(context, "rename-page", {
22
+ page: { index: 0 },
23
+ name: "Doc One Page A",
24
+ });
25
+ expectToolSuccess(renamedDocOnePayload);
26
+ const docOneCurrentPage = unwrapToolPayload(renamedDocOnePayload);
27
+ expect(docOneCurrentPage.name).toBe("Doc One Page A");
28
+ const { payload: createdDocOnePagePayload } = await callToolJson(context, "create-page", {
29
+ name: "Doc One Page B",
30
+ });
31
+ expectToolSuccess(createdDocOnePagePayload);
32
+ const docOneSecondPage = unwrapToolPayload(createdDocOnePagePayload);
33
+ expect(docOneSecondPage.name).toBe("Doc One Page B");
34
+ const { payload: singleDocumentPayload } = await callToolJson(context, "list-documents", {});
35
+ expectToolSuccess(singleDocumentPayload);
36
+ const [docOne] = unwrapToolPayload(singleDocumentPayload);
37
+ expect(docOne).toMatchObject({
38
+ page_count: 2,
39
+ current_page: {
40
+ id: docOneCurrentPage.id,
41
+ name: "Doc One Page A",
42
+ is_current: true,
43
+ },
44
+ });
45
+ const secondPage = await openConnectedDrawioPage({
46
+ browser: context.browser,
47
+ httpPort: context.httpPort,
48
+ wsPort: context.wsPort,
49
+ browserMessages: context.browserMessages,
50
+ });
51
+ try {
52
+ const { payload: twoDocumentsPayload } = await callToolJson(context, "list-documents", {});
53
+ expectToolSuccess(twoDocumentsPayload);
54
+ const documents = unwrapToolPayload(twoDocumentsPayload);
55
+ expect(documents).toHaveLength(2);
56
+ const refreshedDocOne = documents.find((document) => document.id === docOne.id);
57
+ const docTwo = documents.find((document) => document.id !== docOne.id);
58
+ expect(refreshedDocOne?.page_count).toBe(2);
59
+ expect(docTwo).toBeDefined();
60
+ const { payload: renamedDocTwoPayload } = await callToolJson(context, "rename-page", {
61
+ target_document: { id: docTwo.id },
62
+ page: { index: 0 },
63
+ name: "Doc Two Page Only",
64
+ });
65
+ expectToolSuccess(renamedDocTwoPayload);
66
+ const docTwoCurrentPage = unwrapToolPayload(renamedDocTwoPayload);
67
+ expect(docTwoCurrentPage.name).toBe("Doc Two Page Only");
68
+ const ambiguousListPages = (await context.client.callTool({
69
+ name: "list-pages",
70
+ arguments: {},
71
+ }));
72
+ expect(ambiguousListPages.isError).toBe(true);
73
+ expect(firstText(ambiguousListPages)).toContain("Multiple Draw.io documents are connected");
74
+ const { payload: docOnePagesPayload } = await callToolJson(context, "list-pages", {
75
+ target_document: { id: docOne.id },
76
+ });
77
+ expectToolSuccess(docOnePagesPayload);
78
+ const docOnePages = unwrapToolPayload(docOnePagesPayload);
79
+ expect(docOnePages.map((page) => page.name)).toEqual([
80
+ "Doc One Page A",
81
+ "Doc One Page B",
82
+ ]);
83
+ const { payload: docTwoPagesPayload } = await callToolJson(context, "list-pages", {
84
+ target_document: { id: docTwo.id },
85
+ });
86
+ expectToolSuccess(docTwoPagesPayload);
87
+ const docTwoPages = unwrapToolPayload(docTwoPagesPayload);
88
+ expect(docTwoPages).toEqual([
89
+ {
90
+ index: 0,
91
+ id: docTwoCurrentPage.id,
92
+ name: "Doc Two Page Only",
93
+ is_current: true,
94
+ },
95
+ ]);
96
+ const { payload: finalDocumentsPayload } = await callToolJson(context, "list-documents", {});
97
+ expectToolSuccess(finalDocumentsPayload);
98
+ const finalDocuments = unwrapToolPayload(finalDocumentsPayload);
99
+ const finalDocOne = finalDocuments.find((document) => document.id === docOne.id);
100
+ const finalDocTwo = finalDocuments.find((document) => document.id === docTwo.id);
101
+ expect(finalDocOne).toMatchObject({
102
+ page_count: 2,
103
+ current_page: {
104
+ id: docOneCurrentPage.id,
105
+ name: "Doc One Page A",
106
+ is_current: true,
107
+ },
108
+ });
109
+ expect(finalDocTwo).toMatchObject({
110
+ page_count: 1,
111
+ current_page: {
112
+ id: docTwoCurrentPage.id,
113
+ name: "Doc Two Page Only",
114
+ is_current: true,
115
+ },
116
+ });
117
+ }
118
+ finally {
119
+ await secondPage.close();
120
+ }
121
+ await expectNoBrowserErrors(context, "document-targeting");
122
+ await expectNoServerErrors(context, "document-targeting", logCountBefore);
123
+ }, 180000);
124
+ });
@@ -334,7 +334,7 @@ describe("real environment/export-diagram", () => {
334
334
  const plainSvg = findText(plainContent, "<svg");
335
335
  expect(plainSvg).toBeDefined();
336
336
  expect(plainSvg.text).not.toContain("mxfile");
337
- expect(embeddedData.length).toBeGreaterThan(plainSvg.text.length);
337
+ expect(embeddedData).not.toBe(plainSvg.text);
338
338
  const metaContent = findText(embeddedContent, "Exported svg");
339
339
  expect(metaContent).toBeDefined();
340
340
  expect(metaContent.text).toContain("image/svg+xml");
@@ -1,27 +1,81 @@
1
1
  import { chromium, } from "@playwright/test";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import { spawnCaddy } from "drawio-mcp-dev-proxy";
5
+ import { createServer } from "node:net";
4
6
  import { ensureAssets } from "../assets/index.js";
5
- import { getHttpFeatureConfig } from "../config.js";
7
+ import { defaultConfig, getHttpFeatureConfig, } from "../config.js";
6
8
  import { createDrawioMcpApp } from "../index.js";
7
9
  import { MemoryLogger } from "./logger.js";
8
10
  import { createArtifactRunDir } from "./screenshot.js";
11
+ async function getFreePort() {
12
+ return new Promise((resolve, reject) => {
13
+ const srv = createServer();
14
+ srv.once("error", reject);
15
+ srv.listen(0, "127.0.0.1", () => {
16
+ const addr = srv.address();
17
+ if (addr && typeof addr === "object") {
18
+ const port = addr.port;
19
+ srv.close(() => resolve(port));
20
+ }
21
+ else {
22
+ srv.close(() => reject(new Error("could not resolve free port")));
23
+ }
24
+ });
25
+ });
26
+ }
9
27
  export async function createRealEnvironmentContext() {
10
28
  const logger = new MemoryLogger();
11
29
  const browserMessages = [];
12
30
  const artifactRunDir = await createArtifactRunDir();
13
- await ensureAssets({}, () => undefined);
14
- const app = createDrawioMcpApp({ log: logger });
31
+ await ensureAssets({}, logger);
32
+ const app = createDrawioMcpApp({ config: defaultConfig(), log: logger });
15
33
  const wsServer = await app.startWebSocketServer(0);
16
34
  const wsPort = Number(wsServer.address().port);
17
- const config = {
18
- extensionPort: wsPort,
19
- httpPort: 0,
20
- transports: [],
21
- editorEnabled: true,
22
- };
23
- const startedHttp = await app.startHttpServer(0, config, getHttpFeatureConfig(config));
24
- const httpPort = startedHttp.port;
35
+ const useHttps = process.env.HARNESS_HTTPS === "1";
36
+ let caddy = null;
37
+ let proxyPort = null;
38
+ let webSocketUrl;
39
+ let upstreamHttpPort;
40
+ if (useHttps) {
41
+ proxyPort = await getFreePort();
42
+ const httpUpstream = await getFreePort();
43
+ caddy = await spawnCaddy({
44
+ proxyPort,
45
+ httpUpstream,
46
+ wsUpstream: wsPort,
47
+ });
48
+ webSocketUrl = `wss://localhost:${proxyPort}/ws`;
49
+ const config = {
50
+ extensionPort: wsPort,
51
+ httpPort: httpUpstream,
52
+ transports: ["http"],
53
+ editorEnabled: true,
54
+ webSocketUrl,
55
+ logger: "console",
56
+ tlsEnabled: false,
57
+ tlsAuto: false,
58
+ };
59
+ await app.startHttpServer(httpUpstream, config, getHttpFeatureConfig(config));
60
+ upstreamHttpPort = httpUpstream;
61
+ }
62
+ else {
63
+ const config = {
64
+ extensionPort: wsPort,
65
+ httpPort: 0,
66
+ transports: ["http"],
67
+ editorEnabled: true,
68
+ logger: "console",
69
+ tlsEnabled: false,
70
+ tlsAuto: false,
71
+ };
72
+ const startedHttp = await app.startHttpServer(0, config, getHttpFeatureConfig(config));
73
+ upstreamHttpPort = startedHttp.port;
74
+ }
75
+ const httpPort = useHttps ? proxyPort : upstreamHttpPort;
76
+ const baseUrl = useHttps
77
+ ? `https://localhost:${proxyPort}/`
78
+ : `http://localhost:${upstreamHttpPort}/`;
25
79
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
26
80
  const client = new Client({
27
81
  name: "real-environment-test",
@@ -33,27 +87,23 @@ export async function createRealEnvironmentContext() {
33
87
  client.connect(clientTransport),
34
88
  ]);
35
89
  const browser = await chromium.launch({ headless: true });
36
- const page = await browser.newPage();
37
- page.on("console", (message) => {
38
- browserMessages.push({
39
- type: message.type(),
40
- text: message.text(),
41
- });
90
+ const browserContext = await browser.newContext({
91
+ ignoreHTTPSErrors: useHttps,
42
92
  });
43
- await page.addInitScript((port) => {
44
- const store = {
45
- websocketPort: port,
46
- };
47
- window.__DRAWIO_MCP_TEST_HOOKS__ = true;
48
- window.localStorage.setItem("drawio-mcp-plugin-config", JSON.stringify(store));
49
- window.localStorage.setItem("drawio-mcp-config", JSON.stringify(store));
50
- }, wsPort);
51
- await page.goto(`http://localhost:${httpPort}/`, {
52
- waitUntil: "domcontentloaded",
93
+ const page = await openConnectedDrawioPage({
94
+ browserContext,
95
+ baseUrl,
96
+ webSocketUrl,
97
+ httpPort,
98
+ wsPort,
99
+ browserMessages,
53
100
  });
54
- await waitForPluginReady(page);
101
+ const proxy = caddy
102
+ ? { stop: () => caddy.stop(), proxyPort: caddy.proxyPort }
103
+ : null;
55
104
  return {
56
105
  browser,
106
+ browserContext,
57
107
  page,
58
108
  client,
59
109
  app,
@@ -62,12 +112,48 @@ export async function createRealEnvironmentContext() {
62
112
  artifactRunDir,
63
113
  httpPort,
64
114
  wsPort,
115
+ baseUrl,
116
+ proxy,
65
117
  };
66
118
  }
119
+ export async function openConnectedDrawioPage({ browser, browserContext, baseUrl, webSocketUrl, httpPort, wsPort, browserMessages, }) {
120
+ const page = browserContext
121
+ ? await browserContext.newPage()
122
+ : await browser.newPage();
123
+ const editorUrl = baseUrl ?? `http://localhost:${httpPort}/`;
124
+ if (browserMessages) {
125
+ page.on("console", (message) => {
126
+ browserMessages.push({
127
+ type: message.type(),
128
+ text: message.text(),
129
+ });
130
+ });
131
+ }
132
+ await page.addInitScript((args) => {
133
+ const store = {
134
+ websocketPort: args.port,
135
+ };
136
+ if (args.websocketUrl) {
137
+ store.websocketUrl = args.websocketUrl;
138
+ }
139
+ window.__DRAWIO_MCP_TEST_HOOKS__ = true;
140
+ window.localStorage.setItem("drawio-mcp-plugin-config", JSON.stringify(store));
141
+ window.localStorage.setItem("drawio-mcp-config", JSON.stringify(store));
142
+ }, { port: wsPort, websocketUrl: webSocketUrl });
143
+ await page.goto(editorUrl, { waitUntil: "domcontentloaded" });
144
+ await waitForPluginReady(page);
145
+ return page;
146
+ }
67
147
  export async function disposeRealEnvironmentContext(context) {
68
- await context.client.close();
69
- await context.browser.close();
70
- await context.app.close();
148
+ if (!context)
149
+ return;
150
+ await context.client?.close();
151
+ await context.browserContext?.close();
152
+ await context.browser?.close();
153
+ await context.app?.close();
154
+ if (context.proxy) {
155
+ await context.proxy.stop();
156
+ }
71
157
  }
72
158
  export async function waitForPluginReady(page) {
73
159
  await page.waitForFunction(() => {
@@ -160,6 +246,7 @@ export async function resetDiagram(context) {
160
246
  await context.client.callTool({
161
247
  name: "import-diagram",
162
248
  arguments: {
249
+ target_page: { index: 0 },
163
250
  data: emptyDiagram,
164
251
  format: "xml",
165
252
  mode: "replace",
@@ -0,0 +1,81 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
2
+ import { createRealEnvironmentContext, disposeRealEnvironmentContext, resetDiagram, } from "./harness.js";
3
+ import { expectNoBrowserErrors, expectNoServerErrors } from "./assertions.js";
4
+ import { callToolJson } from "./tools.js";
5
+ const FLOWCHART = "graph TD\nA[Start] --> B[Stop]";
6
+ describe("real environment/import-mermaid", () => {
7
+ let context;
8
+ beforeAll(async () => {
9
+ context = await createRealEnvironmentContext();
10
+ }, 180000);
11
+ afterAll(async () => {
12
+ await disposeRealEnvironmentContext(context);
13
+ });
14
+ it("native mode converts a flowchart to native cells on the target page", async () => {
15
+ await resetDiagram(context);
16
+ context.browserMessages.length = 0;
17
+ const logCountBefore = context.logger.entries.length;
18
+ const { payload } = await callToolJson(context, "import-mermaid", {
19
+ target_page: { index: 0 },
20
+ mermaid_source: FLOWCHART,
21
+ mode: "native",
22
+ insert_mode: "add",
23
+ });
24
+ expect(payload.success).toBe(true);
25
+ expect(payload.result?.mode).toBe("native");
26
+ expect(typeof payload.result?.xml).toBe("string");
27
+ expect(payload.result?.xml ?? "").toContain("mxGraphModel");
28
+ await context.page.waitForFunction(() => {
29
+ const ui = window.ui;
30
+ const model = ui?.editor?.graph?.getModel?.();
31
+ const cells = Object.values(model?.cells ?? {});
32
+ return cells.some((cell) => cell?.vertex === true);
33
+ });
34
+ await expectNoBrowserErrors(context, "import-mermaid:native");
35
+ await expectNoServerErrors(context, "import-mermaid:native", logCountBefore);
36
+ }, 180000);
37
+ it("embed mode produces XML carrying the mermaidData attribute", async () => {
38
+ await resetDiagram(context);
39
+ const { payload } = await callToolJson(context, "import-mermaid", {
40
+ target_page: { index: 0 },
41
+ mermaid_source: FLOWCHART,
42
+ mode: "embed",
43
+ insert_mode: "replace",
44
+ });
45
+ expect(payload.success).toBe(true);
46
+ expect(payload.result?.mode).toBe("embed");
47
+ expect(payload.result?.xml ?? "").toContain("mermaidData");
48
+ expect(payload.result?.xml ?? "").toContain("shape=image");
49
+ }, 180000);
50
+ it("requires target_page for replace and add modes", async () => {
51
+ await resetDiagram(context);
52
+ const result = await context.client.callTool({
53
+ name: "import-mermaid",
54
+ arguments: {
55
+ mermaid_source: FLOWCHART,
56
+ mode: "native",
57
+ insert_mode: "add",
58
+ },
59
+ });
60
+ expect(result.isError).toBe(true);
61
+ const content = result.content;
62
+ expect(String(content[0]?.text ?? "")).toContain("`target_page` is required");
63
+ }, 180000);
64
+ it("allows new-page imports without target_page", async () => {
65
+ await resetDiagram(context);
66
+ const { payload: beforePagesPayload } = await callToolJson(context, "list-pages", {});
67
+ expect(beforePagesPayload.success).toBe(true);
68
+ const { payload } = await callToolJson(context, "import-mermaid", {
69
+ mermaid_source: FLOWCHART,
70
+ mode: "native",
71
+ insert_mode: "new-page",
72
+ });
73
+ expect(payload.success).toBe(true);
74
+ expect(payload.result?.mode).toBe("native");
75
+ expect(payload.result?.xml ?? "").toContain("mxGraphModel");
76
+ const { payload: afterPagesPayload } = await callToolJson(context, "list-pages", {});
77
+ expect(afterPagesPayload.success).toBe(true);
78
+ expect(afterPagesPayload.result).toHaveLength(beforePagesPayload.result.length + 1);
79
+ expect(afterPagesPayload.result.at(-1)?.is_current).toBe(true);
80
+ }, 180000);
81
+ });