drawio-mcp-server 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -3
- package/build/assets/downloader.js +16 -17
- package/build/config.js +253 -5
- package/build/config.test.js +449 -1
- package/build/emitter_bus.js +3 -0
- package/build/emitter_bus.test.js +7 -0
- package/build/index.capabilities.test.js +45 -0
- package/build/index.js +496 -106
- package/build/install-desktop-plugin.js +56 -0
- package/build/install-desktop-plugin.test.js +66 -0
- package/build/multi-transport.test.js +277 -0
- package/build/plugin/mcp-plugin.js +2180 -1105
- package/build/prefetch-assets.js +6 -5
- package/build/real-environment/document-targeting.test.js +124 -0
- package/build/real-environment/export-diagram.test.js +473 -0
- package/build/real-environment/harness.js +120 -32
- package/build/real-environment/import-mermaid.test.js +81 -0
- package/build/real-environment/pages-and-concurrency.test.js +448 -0
- package/build/real-environment/shapes.test.js +58 -0
- package/build/real-environment/tools.js +51 -6
- package/build/register-tool.js +31 -0
- package/build/request_queue.js +29 -0
- package/build/request_queue.test.js +98 -0
- package/build/stdio-transport-purity.test.js +88 -0
- package/build/strip-schema.js +61 -0
- package/build/tls/expiry.js +14 -0
- package/build/tls/expiry.test.js +54 -0
- package/build/tls/generate.js +123 -0
- package/build/tls/generate.test.js +115 -0
- package/build/tls/index.js +80 -0
- package/build/tls/index.test.js +141 -0
- package/build/tls/install-hint.js +45 -0
- package/build/tls/install-hint.test.js +32 -0
- package/build/tls/load.js +18 -0
- package/build/tls/load.test.js +40 -0
- package/build/tls/paths.js +30 -0
- package/build/tls/paths.test.js +53 -0
- package/build/tls/san.js +27 -0
- package/build/tls/san.test.js +72 -0
- package/build/tool-registry.test.js +823 -0
- package/build/tool.js +74 -15
- package/build/tool.test.js +250 -7
- package/build/tools/add-cell-of-shape.js +4 -2
- package/build/tools/add-edge.js +3 -1
- package/build/tools/add-rectangle.js +4 -2
- package/build/tools/copy-page.js +13 -0
- package/build/tools/create-layer.js +4 -2
- package/build/tools/create-page.js +8 -0
- package/build/tools/delete-cell-by-id.js +4 -2
- package/build/tools/edit-cell.js +3 -1
- package/build/tools/edit-edge.js +3 -1
- package/build/tools/export-diagram.js +7 -3
- package/build/tools/get-active-layer.js +4 -1
- package/build/tools/get-current-page.js +5 -0
- package/build/tools/get-selected-cell.js +4 -1
- package/build/tools/import-diagram.js +12 -2
- package/build/tools/import-mermaid.js +31 -0
- package/build/tools/index.js +14 -0
- package/build/tools/list-documents.js +17 -0
- package/build/tools/list-layers.js +4 -1
- package/build/tools/list-paged-model.js +4 -3
- package/build/tools/list-pages.js +5 -0
- package/build/tools/move-cell-to-layer.js +3 -1
- package/build/tools/rename-page.js +10 -0
- package/build/tools/set-active-layer.js +4 -2
- package/build/tools/set-cell-data.js +3 -1
- package/build/tools/set-cell-parent.js +3 -1
- package/build/tools/set-cell-shape.js +3 -1
- package/build/tools/shared.js +35 -0
- package/build/tools/shared.test.js +29 -0
- package/package.json +22 -14
package/build/tool.js
CHANGED
|
@@ -1,30 +1,89 @@
|
|
|
1
1
|
import { strip_internal_fields } from "./events.js";
|
|
2
|
-
|
|
3
|
-
const
|
|
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
|
-
...
|
|
21
|
+
...request_payload,
|
|
11
22
|
});
|
|
12
|
-
log.debug(`[${event_name}] emitted, waiting for reply @${reply_name}`);
|
|
13
|
-
const p = new Promise((resolve,
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|
package/build/tool.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
package/build/tools/add-edge.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
};
|
package/build/tools/edit-cell.js
CHANGED
|
@@ -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
|
};
|
package/build/tools/edit-edge.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
};
|