drawio-mcp-server 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +29 -3
  2. package/build/assets/downloader.js +16 -17
  3. package/build/config.js +253 -5
  4. package/build/config.test.js +449 -1
  5. package/build/emitter_bus.js +3 -0
  6. package/build/emitter_bus.test.js +7 -0
  7. package/build/index.capabilities.test.js +45 -0
  8. package/build/index.js +496 -106
  9. package/build/install-desktop-plugin.js +56 -0
  10. package/build/install-desktop-plugin.test.js +66 -0
  11. package/build/multi-transport.test.js +277 -0
  12. package/build/plugin/mcp-plugin.js +2180 -1105
  13. package/build/prefetch-assets.js +6 -5
  14. package/build/real-environment/document-targeting.test.js +124 -0
  15. package/build/real-environment/export-diagram.test.js +473 -0
  16. package/build/real-environment/harness.js +120 -32
  17. package/build/real-environment/import-mermaid.test.js +81 -0
  18. package/build/real-environment/pages-and-concurrency.test.js +448 -0
  19. package/build/real-environment/shapes.test.js +58 -0
  20. package/build/real-environment/tools.js +51 -6
  21. package/build/register-tool.js +31 -0
  22. package/build/request_queue.js +29 -0
  23. package/build/request_queue.test.js +98 -0
  24. package/build/stdio-transport-purity.test.js +88 -0
  25. package/build/strip-schema.js +61 -0
  26. package/build/tls/expiry.js +14 -0
  27. package/build/tls/expiry.test.js +54 -0
  28. package/build/tls/generate.js +123 -0
  29. package/build/tls/generate.test.js +115 -0
  30. package/build/tls/index.js +80 -0
  31. package/build/tls/index.test.js +141 -0
  32. package/build/tls/install-hint.js +45 -0
  33. package/build/tls/install-hint.test.js +32 -0
  34. package/build/tls/load.js +18 -0
  35. package/build/tls/load.test.js +40 -0
  36. package/build/tls/paths.js +30 -0
  37. package/build/tls/paths.test.js +53 -0
  38. package/build/tls/san.js +27 -0
  39. package/build/tls/san.test.js +72 -0
  40. package/build/tool-registry.test.js +823 -0
  41. package/build/tool.js +74 -15
  42. package/build/tool.test.js +250 -7
  43. package/build/tools/add-cell-of-shape.js +4 -2
  44. package/build/tools/add-edge.js +3 -1
  45. package/build/tools/add-rectangle.js +4 -2
  46. package/build/tools/copy-page.js +13 -0
  47. package/build/tools/create-layer.js +4 -2
  48. package/build/tools/create-page.js +8 -0
  49. package/build/tools/delete-cell-by-id.js +4 -2
  50. package/build/tools/edit-cell.js +3 -1
  51. package/build/tools/edit-edge.js +3 -1
  52. package/build/tools/export-diagram.js +7 -3
  53. package/build/tools/get-active-layer.js +4 -1
  54. package/build/tools/get-current-page.js +5 -0
  55. package/build/tools/get-selected-cell.js +4 -1
  56. package/build/tools/import-diagram.js +12 -2
  57. package/build/tools/import-mermaid.js +31 -0
  58. package/build/tools/index.js +14 -0
  59. package/build/tools/list-documents.js +17 -0
  60. package/build/tools/list-layers.js +4 -1
  61. package/build/tools/list-paged-model.js +4 -3
  62. package/build/tools/list-pages.js +5 -0
  63. package/build/tools/move-cell-to-layer.js +3 -1
  64. package/build/tools/rename-page.js +10 -0
  65. package/build/tools/set-active-layer.js +4 -2
  66. package/build/tools/set-cell-data.js +3 -1
  67. package/build/tools/set-cell-parent.js +3 -1
  68. package/build/tools/set-cell-shape.js +3 -1
  69. package/build/tools/shared.js +35 -0
  70. package/build/tools/shared.test.js +29 -0
  71. package/package.json +22 -14
package/build/tool.js CHANGED
@@ -1,30 +1,89 @@
1
1
  import { strip_internal_fields } from "./events.js";
2
- export function build_channel({ bus, id_generator, log }, event_name, handler) {
3
- const fn = async (_args, _extra) => {
2
+ const DEFAULT_REPLY_TIMEOUT_MS = (() => {
3
+ const raw = process.env.DRAWIO_MCP_REPLY_TIMEOUT_MS;
4
+ const parsed = Number(raw);
5
+ if (Number.isFinite(parsed) && parsed > 0) {
6
+ return parsed;
7
+ }
8
+ return 60_000;
9
+ })();
10
+ export function build_channel({ bus, id_generator, request_queue, document_routing, log }, event_name, handler, options = {}) {
11
+ const routing = options.routing ?? "document";
12
+ const invoke = async (request_payload, queue_key) => {
13
+ const reply_timeout_ms = options.reply_timeout_ms && options.reply_timeout_ms > 0
14
+ ? options.reply_timeout_ms
15
+ : DEFAULT_REPLY_TIMEOUT_MS;
4
16
  const request_id = id_generator.generate();
5
- // const event_name = `get-selected-cell`;
6
17
  const reply_name = `${event_name}.${request_id}`;
7
18
  bus.send_to_extension({
8
19
  __event: event_name,
9
20
  __request_id: request_id,
10
- ..._args,
21
+ ...request_payload,
11
22
  });
12
- log.debug(`[${event_name}] emitted, waiting for reply @${reply_name}`);
13
- const p = new Promise((resolve, _reject) => {
23
+ log.debug(`[${event_name}] emitted on ${queue_key}, waiting for reply @${reply_name}`);
24
+ const p = new Promise((resolve, reject) => {
14
25
  log.debug(`[${event_name}] waiting for response @${reply_name}`);
15
- bus.on_reply_from_extension(reply_name, (reply) => {
26
+ let settled = false;
27
+ let timeout_handle;
28
+ let cleanup;
29
+ const finish = (finalize) => {
30
+ if (settled) {
31
+ return;
32
+ }
33
+ settled = true;
34
+ if (timeout_handle) {
35
+ clearTimeout(timeout_handle);
36
+ }
37
+ cleanup?.();
38
+ finalize();
39
+ };
40
+ timeout_handle = setTimeout(() => {
41
+ finish(() => {
42
+ const error = new Error(`Timed out waiting for reply to \`${event_name}\` after ${reply_timeout_ms}ms`);
43
+ log.log("warn", `[${reply_name}] ${error.message}`);
44
+ reject(error);
45
+ });
46
+ }, reply_timeout_ms);
47
+ cleanup = bus.on_reply_from_extension(reply_name, (reply) => {
16
48
  // bus.on(reply_name, (args) => {
17
- log.debug(`[${reply_name}] received response`, reply);
18
- const data = strip_internal_fields(reply);
19
- const response = handler(data);
20
- resolve(response);
49
+ finish(() => {
50
+ log.debug(`[${reply_name}] received response`, reply);
51
+ const data = strip_internal_fields(reply);
52
+ try {
53
+ const response = handler(data);
54
+ resolve(response);
55
+ }
56
+ catch (error) {
57
+ reject(error);
58
+ }
59
+ });
21
60
  });
61
+ if (settled) {
62
+ cleanup?.();
63
+ }
22
64
  });
23
65
  return p;
24
66
  };
67
+ const fn = async (_args, _extra) => {
68
+ let request_payload = { ..._args };
69
+ let queue_key = "global";
70
+ if (routing === "document") {
71
+ const resolved = await document_routing.resolve_target_document(request_payload);
72
+ queue_key = resolved.connection_id;
73
+ request_payload = {
74
+ ...request_payload,
75
+ target_document: resolved.target_document,
76
+ __target_connection_id: resolved.connection_id,
77
+ };
78
+ }
79
+ if (options.queue) {
80
+ return request_queue.enqueue(queue_key, () => invoke(request_payload, queue_key));
81
+ }
82
+ return invoke(request_payload, queue_key);
83
+ };
25
84
  return fn;
26
85
  }
27
- export function default_tool(name, context) {
86
+ export function default_tool(name, context, options = {}) {
28
87
  const fn = build_channel(context, name, (reply) => {
29
88
  const response = {
30
89
  content: [
@@ -35,10 +94,10 @@ export function default_tool(name, context) {
35
94
  ],
36
95
  };
37
96
  return response;
38
- });
97
+ }, options);
39
98
  return fn;
40
99
  }
41
- export function export_tool_handler(name, context) {
100
+ export function export_tool_handler(name, context, options = {}) {
42
101
  const fn = build_channel(context, name, (reply) => {
43
102
  const { success, result, error } = reply;
44
103
  if (!success) {
@@ -81,6 +140,6 @@ export function export_tool_handler(name, context) {
81
140
  content,
82
141
  };
83
142
  return response;
84
- });
143
+ }, options);
85
144
  return fn;
86
145
  }
@@ -1,30 +1,72 @@
1
1
  import { jest } from "@jest/globals";
2
2
  import { build_channel, default_tool } from "./tool.js";
3
3
  import { create_logger } from "./standard_console_logger.js";
4
+ import { create_request_queue } from "./request_queue.js";
4
5
  describe("build_channel", () => {
5
6
  let mockBus;
6
7
  let mockIdGenerator;
8
+ let mockRequestQueue;
9
+ let mockDocumentRouting;
7
10
  let context;
8
11
  const mockHandler = jest.fn();
9
12
  const log = create_logger();
13
+ const resolvedDocument = {
14
+ id: "doc-1",
15
+ title: "Document One",
16
+ mode: "local",
17
+ hash: null,
18
+ file_url: null,
19
+ page_count: 1,
20
+ current_page: {
21
+ index: 0,
22
+ id: "page-1",
23
+ name: "Page 1",
24
+ is_current: true,
25
+ },
26
+ };
10
27
  beforeEach(() => {
11
28
  mockBus = {
12
29
  send_to_extension: jest.fn(),
13
- on_reply_from_extension: jest.fn(),
30
+ on_reply_from_extension: jest.fn(() => jest.fn()),
14
31
  };
15
32
  mockIdGenerator = {
16
33
  generate: jest.fn().mockReturnValue("123"),
17
34
  };
35
+ mockRequestQueue = {
36
+ enqueue: jest.fn((_, task) => task()),
37
+ };
38
+ mockDocumentRouting = {
39
+ list_documents: jest.fn(async () => [resolvedDocument]),
40
+ resolve_target_document: jest.fn(async () => ({
41
+ connection_id: "conn-1",
42
+ target_document: { id: resolvedDocument.id },
43
+ document: resolvedDocument,
44
+ })),
45
+ };
18
46
  context = {
19
47
  bus: mockBus,
20
48
  id_generator: mockIdGenerator,
49
+ request_queue: mockRequestQueue,
50
+ document_routing: mockDocumentRouting,
21
51
  log,
22
52
  };
23
53
  mockHandler.mockReset();
24
54
  });
55
+ afterEach(() => {
56
+ jest.useRealTimers();
57
+ });
58
+ async function flushMicrotasks() {
59
+ await Promise.resolve();
60
+ await Promise.resolve();
61
+ }
25
62
  it("should create a function that sends a message via bus", async () => {
26
63
  const eventName = "test-event";
27
- const toolFn = build_channel(context, eventName, mockHandler);
64
+ const toolFn = build_channel(context, eventName, mockHandler, {
65
+ routing: "none",
66
+ });
67
+ mockHandler.mockReturnValue({
68
+ content: [{ type: "text", text: "ok" }],
69
+ });
28
70
  const args = { key: "value" };
29
71
  const extra = {};
30
72
  const promise = toolFn(args, extra);
@@ -33,10 +75,17 @@ describe("build_channel", () => {
33
75
  __request_id: "123",
34
76
  key: "value",
35
77
  });
78
+ const replyCallback = mockBus.on_reply_from_extension.mock.calls[0][1];
79
+ replyCallback({ ok: true });
80
+ await expect(promise).resolves.toEqual({
81
+ content: [{ type: "text", text: "ok" }],
82
+ });
36
83
  });
37
84
  it("should wait for reply and call handler with response", async () => {
38
85
  const eventName = "test-event";
39
- const toolFn = build_channel(context, eventName, mockHandler);
86
+ const toolFn = build_channel(context, eventName, mockHandler, {
87
+ routing: "none",
88
+ });
40
89
  const mockResponse = {
41
90
  content: [{ type: "text", text: "response" }],
42
91
  };
@@ -53,14 +102,170 @@ describe("build_channel", () => {
53
102
  it("should use correct reply channel name format", async () => {
54
103
  mockIdGenerator.generate.mockReturnValue("456");
55
104
  const eventName = "another-event";
56
- const toolFn = build_channel(context, eventName, mockHandler);
57
- toolFn({}, {});
105
+ const toolFn = build_channel(context, eventName, mockHandler, {
106
+ routing: "none",
107
+ });
108
+ mockHandler.mockReturnValue({
109
+ content: [{ type: "text", text: "ok" }],
110
+ });
111
+ const promise = toolFn({}, {});
58
112
  expect(mockBus.on_reply_from_extension).toHaveBeenCalledWith("another-event.456", expect.any(Function));
113
+ const replyCallback = mockBus.on_reply_from_extension.mock.calls[0][1];
114
+ replyCallback({ ok: true });
115
+ await expect(promise).resolves.toEqual({
116
+ content: [{ type: "text", text: "ok" }],
117
+ });
118
+ });
119
+ it("should enqueue queued tools through the shared request queue", async () => {
120
+ const eventName = "queued-event";
121
+ const toolFn = build_channel(context, eventName, mockHandler, {
122
+ queue: true,
123
+ routing: "none",
124
+ });
125
+ mockHandler.mockReturnValue({
126
+ content: [{ type: "text", text: "queued" }],
127
+ });
128
+ const promise = toolFn({}, {});
129
+ const replyCallback = mockBus.on_reply_from_extension.mock.calls[0][1];
130
+ replyCallback({ data: "queued" });
131
+ await promise;
132
+ expect(mockRequestQueue.enqueue).toHaveBeenCalledTimes(1);
133
+ expect(mockRequestQueue.enqueue).toHaveBeenCalledWith("global", expect.any(Function));
134
+ });
135
+ it("should time out pending requests and unsubscribe the reply listener", async () => {
136
+ jest.useFakeTimers();
137
+ const eventName = "timeout-event";
138
+ const unsubscribe = jest.fn();
139
+ mockBus.on_reply_from_extension.mockReturnValue(unsubscribe);
140
+ const toolFn = build_channel(context, eventName, mockHandler, {
141
+ reply_timeout_ms: 25,
142
+ routing: "none",
143
+ });
144
+ const promise = toolFn({}, {});
145
+ const failure = expect(promise).rejects.toThrow("Timed out waiting for reply to `timeout-event` after 25ms");
146
+ await jest.advanceTimersByTimeAsync(25);
147
+ await failure;
148
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
149
+ });
150
+ it("should continue queued processing after a timed out request", async () => {
151
+ jest.useFakeTimers();
152
+ const listeners = new Map();
153
+ const toolFn = build_channel({
154
+ ...context,
155
+ request_queue: create_request_queue(log),
156
+ }, "queued-timeout-event", (reply) => ({
157
+ content: [{ type: "text", text: JSON.stringify(reply) }],
158
+ }), {
159
+ queue: true,
160
+ reply_timeout_ms: 25,
161
+ routing: "none",
162
+ });
163
+ mockIdGenerator.generate
164
+ .mockReturnValueOnce("first")
165
+ .mockReturnValueOnce("second");
166
+ mockBus.on_reply_from_extension.mockImplementation((event_name, listener) => {
167
+ listeners.set(event_name, listener);
168
+ return () => {
169
+ listeners.delete(event_name);
170
+ };
171
+ });
172
+ const first = toolFn({}, {});
173
+ const second = toolFn({}, {});
174
+ await Promise.resolve();
175
+ expect(listeners.has("queued-timeout-event.first")).toBe(true);
176
+ expect(listeners.has("queued-timeout-event.second")).toBe(false);
177
+ const firstFailure = expect(first).rejects.toThrow("Timed out waiting for reply to `queued-timeout-event` after 25ms");
178
+ await jest.advanceTimersByTimeAsync(25);
179
+ await firstFailure;
180
+ await Promise.resolve();
181
+ expect(listeners.has("queued-timeout-event.second")).toBe(true);
182
+ listeners.get("queued-timeout-event.second")?.({
183
+ __event: "queued-timeout-event.second",
184
+ data: "ok",
185
+ });
186
+ await expect(second).resolves.toEqual({
187
+ content: [{ type: "text", text: JSON.stringify({ data: "ok" }) }],
188
+ });
189
+ });
190
+ it("resolves the target document by default and injects it into the request", async () => {
191
+ const toolFn = build_channel(context, "document-event", mockHandler, {
192
+ queue: true,
193
+ });
194
+ mockHandler.mockReturnValue({
195
+ content: [{ type: "text", text: "ok" }],
196
+ });
197
+ const promise = toolFn({ key: "value" }, {});
198
+ await Promise.resolve();
199
+ const replyCallback = mockBus.on_reply_from_extension.mock.calls[0][1];
200
+ replyCallback({ ok: true });
201
+ await promise;
202
+ expect(mockDocumentRouting.resolve_target_document).toHaveBeenCalledWith({
203
+ key: "value",
204
+ });
205
+ expect(mockRequestQueue.enqueue).toHaveBeenCalledWith("conn-1", expect.any(Function));
206
+ expect(mockBus.send_to_extension).toHaveBeenCalledWith({
207
+ __event: "document-event",
208
+ __request_id: "123",
209
+ key: "value",
210
+ target_document: { id: "doc-1" },
211
+ __target_connection_id: "conn-1",
212
+ });
213
+ });
214
+ it("propagates document routing errors before sending any request", async () => {
215
+ mockDocumentRouting.resolve_target_document.mockRejectedValueOnce(new Error("No connected Draw.io documents"));
216
+ const toolFn = build_channel(context, "document-event", mockHandler);
217
+ await expect(toolFn({}, {})).rejects.toThrow("No connected Draw.io documents");
218
+ expect(mockBus.send_to_extension).not.toHaveBeenCalled();
219
+ });
220
+ it("fails queued document requests immediately when delivery breaks after waiting in queue", async () => {
221
+ const listeners = new Map();
222
+ let sendCount = 0;
223
+ mockIdGenerator.generate
224
+ .mockReturnValueOnce("first")
225
+ .mockReturnValueOnce("second");
226
+ mockBus.send_to_extension.mockImplementation(() => {
227
+ sendCount += 1;
228
+ if (sendCount === 2) {
229
+ throw new Error("Target document doc-1 is no longer connected; call list-documents and retry");
230
+ }
231
+ });
232
+ mockBus.on_reply_from_extension.mockImplementation((event_name, listener) => {
233
+ listeners.set(event_name, listener);
234
+ return () => {
235
+ listeners.delete(event_name);
236
+ };
237
+ });
238
+ const toolFn = build_channel({
239
+ ...context,
240
+ request_queue: create_request_queue(log),
241
+ }, "document-event", (reply) => ({
242
+ content: [{ type: "text", text: JSON.stringify(reply) }],
243
+ }), {
244
+ queue: true,
245
+ });
246
+ const first = toolFn({}, {});
247
+ const second = toolFn({}, {});
248
+ await flushMicrotasks();
249
+ expect(mockBus.send_to_extension).toHaveBeenCalledTimes(1);
250
+ expect(listeners.has("document-event.first")).toBe(true);
251
+ expect(listeners.has("document-event.second")).toBe(false);
252
+ listeners.get("document-event.first")?.({
253
+ __event: "document-event.first",
254
+ ok: true,
255
+ });
256
+ await expect(first).resolves.toEqual({
257
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
258
+ });
259
+ await expect(second).rejects.toThrow("Target document doc-1 is no longer connected; call list-documents and retry");
260
+ expect(mockBus.send_to_extension).toHaveBeenCalledTimes(2);
261
+ expect(listeners.has("document-event.second")).toBe(false);
59
262
  });
60
263
  });
61
264
  describe("default_tool", () => {
62
265
  let mockBus;
63
266
  let mockIdGenerator;
267
+ let mockRequestQueue;
268
+ let mockDocumentRouting;
64
269
  const log = create_logger();
65
270
  let context;
66
271
  beforeEach(() => {
@@ -68,20 +273,47 @@ describe("default_tool", () => {
68
273
  send_to_extension: jest.fn(),
69
274
  on_reply_from_extension: jest.fn((_, callback) => {
70
275
  callback({ test: "data" });
276
+ return jest.fn();
71
277
  }),
72
278
  };
73
279
  mockIdGenerator = {
74
280
  generate: jest.fn().mockReturnValue("789"),
75
281
  };
282
+ mockRequestQueue = {
283
+ enqueue: jest.fn((_, task) => task()),
284
+ };
285
+ mockDocumentRouting = {
286
+ list_documents: jest.fn(async () => []),
287
+ resolve_target_document: jest.fn(async () => ({
288
+ connection_id: "conn-1",
289
+ target_document: { id: "doc-1" },
290
+ document: {
291
+ id: "doc-1",
292
+ title: "Document One",
293
+ mode: "local",
294
+ hash: null,
295
+ file_url: null,
296
+ page_count: 1,
297
+ current_page: {
298
+ index: 0,
299
+ id: "page-1",
300
+ name: "Page 1",
301
+ is_current: true,
302
+ },
303
+ },
304
+ })),
305
+ };
76
306
  context = {
77
307
  bus: mockBus,
78
308
  id_generator: mockIdGenerator,
309
+ request_queue: mockRequestQueue,
310
+ document_routing: mockDocumentRouting,
79
311
  log,
80
312
  };
81
313
  });
82
314
  it("should create a tool that returns JSON stringified response", async () => {
83
315
  const toolName = "default-tool";
84
- const tool = default_tool(toolName, context);
316
+ const tool = default_tool(toolName, context, { routing: "none" });
85
317
  const result = await tool({}, {});
86
318
  expect(result).toEqual({
87
319
  content: [
@@ -94,11 +326,22 @@ describe("default_tool", () => {
94
326
  });
95
327
  it("should use the provided tool name in the channel", async () => {
96
328
  const toolName = "custom-tool";
97
- const tool = default_tool(toolName, context);
329
+ const tool = default_tool(toolName, context, { routing: "none" });
98
330
  await tool({}, {});
99
331
  expect(mockBus.send_to_extension).toHaveBeenCalledWith({
100
332
  __event: toolName,
101
333
  __request_id: "789",
102
334
  });
103
335
  });
336
+ it("routes default tools through the resolved document by default", async () => {
337
+ const tool = default_tool("custom-tool", context);
338
+ await tool({}, {});
339
+ expect(mockDocumentRouting.resolve_target_document).toHaveBeenCalledWith({});
340
+ expect(mockBus.send_to_extension).toHaveBeenCalledWith({
341
+ __event: "custom-tool",
342
+ __request_id: "789",
343
+ target_document: { id: "doc-1" },
344
+ __target_connection_id: "conn-1",
345
+ });
346
+ });
104
347
  });
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_add_cell_of_shape = "add-cell-of-shape";
4
5
  export const registerAddCellOfShapeTool = (server, context) => {
5
- server.tool(TOOL_add_cell_of_shape, "This tool allows you to add new vertex cell (object) on the current page of a Draw.io diagram by its shape name. It accepts multiple optional input parameter.", {
6
+ server.tool(TOOL_add_cell_of_shape, "This tool allows you to add a new vertex cell on the target page of the current Draw.io document by its shape name.", {
7
+ target_page: target_page_field(),
6
8
  shape_name: z
7
9
  .string()
8
10
  .describe("Name of the shape to retrieved from the shape library of the current diagram."),
@@ -38,5 +40,5 @@ export const registerAddCellOfShapeTool = (server, context) => {
38
40
  .string()
39
41
  .optional()
40
42
  .describe("ID of the parent cell. If provided, the new cell will be created as a child of this cell. If omitted, the cell is created at the diagram root level."),
41
- }, default_tool(TOOL_add_cell_of_shape, context));
43
+ }, default_tool(TOOL_add_cell_of_shape, context, { queue: true }));
42
44
  };
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_add_edge = "add-edge";
4
5
  export const registerAddEdgeTool = (server, context) => {
5
6
  server.tool(TOOL_add_edge, "This tool creates an edge, sometimes called also a relation, between two vertexes (cells). When source and target are the same shape (self-connector), a loop edge style is automatically applied if no custom style is provided.", {
7
+ target_page: target_page_field(),
6
8
  source_id: z
7
9
  .string()
8
10
  .describe("Source ID of a cell. It is represented by `id` attribute."),
@@ -29,5 +31,5 @@ export const registerAddEdgeTool = (server, context) => {
29
31
  .string()
30
32
  .optional()
31
33
  .describe("ID of the parent cell. If provided, the new edge will be created as a child of this cell. If omitted, the edge is created at the diagram root level."),
32
- }, default_tool(TOOL_add_edge, context));
34
+ }, default_tool(TOOL_add_edge, context, { queue: true }));
33
35
  };
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_add_rectangle = "add-rectangle";
4
5
  export const registerAddRectangleTool = (server, context) => {
5
- server.tool(TOOL_add_rectangle, "This tool allows you to add new Rectangle vertex cell (object) on the current page of a Draw.io diagram. It accepts multiple optional input parameter.", {
6
+ server.tool(TOOL_add_rectangle, "This tool allows you to add a new Rectangle vertex cell on the target page of the current Draw.io document.", {
7
+ target_page: target_page_field(),
6
8
  x: z
7
9
  .number()
8
10
  .optional()
@@ -37,5 +39,5 @@ export const registerAddRectangleTool = (server, context) => {
37
39
  .string()
38
40
  .optional()
39
41
  .describe("ID of the parent cell. If provided, the new rectangle will be created as a child of this cell. If omitted, the rectangle is created at the diagram root level."),
40
- }, default_tool(TOOL_add_rectangle, context));
42
+ }, default_tool(TOOL_add_rectangle, context, { queue: true }));
41
43
  };
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
4
+ export const TOOL_copy_page = "copy-page";
5
+ export const registerCopyPageTool = (server, context) => {
6
+ server.tool(TOOL_copy_page, "Creates a copy of an existing page in the target/current Draw.io document, appends the copy to the end of the page list, and returns the copied page metadata. When possible, the previously visible page is restored after the copy is created.", {
7
+ page: target_page_field().describe("Source page selector for the page to copy. Provide exactly one of `{ index }` or `{ id }`."),
8
+ name: z
9
+ .string()
10
+ .optional()
11
+ .describe("Optional name for the copied page."),
12
+ }, default_tool(TOOL_copy_page, context, { queue: true }));
13
+ };
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_create_layer = "create-layer";
4
5
  export const registerCreateLayerTool = (server, context) => {
5
- server.tool(TOOL_create_layer, "Creates a new layer in the diagram.", {
6
+ server.tool(TOOL_create_layer, "Creates a new layer on the target page.", {
7
+ target_page: target_page_field(),
6
8
  name: z.string().describe("Name for the new layer"),
7
- }, default_tool(TOOL_create_layer, context));
9
+ }, default_tool(TOOL_create_layer, context, { queue: true }));
8
10
  };
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ import { default_tool } from "../tool.js";
3
+ export const TOOL_create_page = "create-page";
4
+ export const registerCreatePageTool = (server, context) => {
5
+ server.tool(TOOL_create_page, "Appends a new blank page to the current Draw.io document without changing the visible page when the runtime supports background page insertion.", {
6
+ name: z.string().describe("Name for the new page"),
7
+ }, default_tool(TOOL_create_page, context, { queue: true }));
8
+ };
@@ -1,10 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_delete_cell_by_id = "delete-cell-by-id";
4
5
  export const registerDeleteCellByIdTool = (server, context) => {
5
- server.tool(TOOL_delete_cell_by_id, "Deletes a cell, whether it is a vertex or edge.", {
6
+ server.tool(TOOL_delete_cell_by_id, "Deletes a cell from the target page, whether it is a vertex or edge.", {
7
+ target_page: target_page_field(),
6
8
  cell_id: z
7
9
  .string()
8
10
  .describe("The ID of a cell to delete. The cell can be either vertex or edge. The ID is located in `id` attribute."),
9
- }, default_tool(TOOL_delete_cell_by_id, context));
11
+ }, default_tool(TOOL_delete_cell_by_id, context, { queue: true }));
10
12
  };
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_edit_cell = "edit-cell";
4
5
  export const registerEditCellTool = (server, context) => {
5
6
  server.tool(TOOL_edit_cell, "Update properties of an existing vertex/shape cell by its ID. Only provided fields are modified; unspecified properties remain unchanged.", {
7
+ target_page: target_page_field(),
6
8
  cell_id: z
7
9
  .string()
8
10
  .describe("Identifier (`id` attribute) of the cell to update. Applies to vertex/shape cells."),
@@ -24,5 +26,5 @@ export const registerEditCellTool = (server, context) => {
24
26
  .string()
25
27
  .optional()
26
28
  .describe("Replace the cell's style string (semi-colon separated `key=value` pairs)."),
27
- }, default_tool(TOOL_edit_cell, context));
29
+ }, default_tool(TOOL_edit_cell, context, { queue: true }));
28
30
  };
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { default_tool } from "../tool.js";
3
+ import { target_page_field } from "./shared.js";
3
4
  export const TOOL_edit_edge = "edit-edge";
4
5
  export const registerEditEdgeTool = (server, context) => {
5
6
  server.tool(TOOL_edit_edge, "Update properties of an existing edge by its ID. Only provided fields are modified; unspecified properties remain unchanged. Supports setting waypoints for edge geometry control.", {
7
+ target_page: target_page_field(),
6
8
  cell_id: z
7
9
  .string()
8
10
  .describe("Identifier (`id` attribute) of the edge cell to update. The ID must reference an edge."),
@@ -26,5 +28,5 @@ export const registerEditEdgeTool = (server, context) => {
26
28
  }))
27
29
  .optional()
28
30
  .describe("Array of {x, y} waypoints to set as edge geometry control points. Replaces existing waypoints. Use an empty array to clear waypoints."),
29
- }, default_tool(TOOL_edit_edge, context));
31
+ }, default_tool(TOOL_edit_edge, context, { queue: true }));
30
32
  };
@@ -1,9 +1,11 @@
1
1
  import { writeFileSync, existsSync } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { export_tool_handler } from "../tool.js";
4
+ import { target_page_field } from "./shared.js";
4
5
  export const TOOL_export_diagram = "export-diagram";
5
6
  export const registerExportDiagramTool = (server, context) => {
6
- server.tool(TOOL_export_diagram, "Export the current diagram as SVG, PNG, or XML. Returns the diagram data as base64 (PNG) or text (SVG/XML). Optionally saves to a file.", {
7
+ server.tool(TOOL_export_diagram, "Export the target page or current diagram as SVG, PNG, or XML. Returns the diagram data as base64 (PNG) or text (SVG/XML). Optionally saves to a file.", {
8
+ target_page: target_page_field(),
7
9
  format: z
8
10
  .enum(["svg", "png", "xml"])
9
11
  .describe("Export format: svg for vector graphics, png for raster image, xml for raw diagram data"),
@@ -56,13 +58,15 @@ export const registerExportDiagramTool = (server, context) => {
56
58
  .enum(["selection", "page", "diagram"])
57
59
  .optional()
58
60
  .default("diagram")
59
- .describe("What to export: 'selection' for selected cells only, 'page' for current page, 'diagram' for entire model"),
61
+ .describe("What to export: 'selection' for selected cells only, 'page' for the target page, 'diagram' for the entire model"),
60
62
  output_path: z
61
63
  .string()
62
64
  .optional()
63
65
  .describe("Absolute file path to save the exported file (must be an absolute path)"),
64
66
  }, async (args, _extra) => {
65
- const exportHandler = export_tool_handler(TOOL_export_diagram, context);
67
+ const exportHandler = export_tool_handler(TOOL_export_diagram, context, {
68
+ queue: true,
69
+ });
66
70
  const result = await exportHandler(args, _extra);
67
71
  if (args.output_path) {
68
72
  const path = await import("node:path");
@@ -1,5 +1,8 @@
1
1
  import { default_tool } from "../tool.js";
2
+ import { target_page_field } from "./shared.js";
2
3
  export const TOOL_get_active_layer = "get-active-layer";
3
4
  export const registerGetActiveLayerTool = (server, context) => {
4
- server.tool(TOOL_get_active_layer, "Gets the currently active layer information.", {}, default_tool(TOOL_get_active_layer, context));
5
+ server.tool(TOOL_get_active_layer, "Gets the currently active layer information for the target page. If the target page is not currently visible, Draw.io may switch the visible page first because active-layer state is UI-bound.", {
6
+ target_page: target_page_field(),
7
+ }, default_tool(TOOL_get_active_layer, context, { queue: true }));
5
8
  };
@@ -0,0 +1,5 @@
1
+ import { default_tool } from "../tool.js";
2
+ export const TOOL_get_current_page = "get-current-page";
3
+ export const registerGetCurrentPageTool = (server, context) => {
4
+ server.tool(TOOL_get_current_page, "Gets the currently visible page metadata, including index, id, name, and whether it is active.", {}, default_tool(TOOL_get_current_page, context, { queue: true }));
5
+ };