drawio-mcp-server 2.2.0 → 2.3.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 +21 -68
- package/build/index.js +41 -2
- package/build/install/config-io.js +21 -0
- package/build/install/config-io.test.js +37 -0
- package/build/install/hosts/claude-code.js +37 -0
- package/build/install/hosts/claude-code.test.js +47 -0
- package/build/install/hosts/claude-desktop.js +45 -0
- package/build/install/hosts/claude-desktop.test.js +57 -0
- package/build/install/hosts/codex.js +171 -0
- package/build/install/hosts/codex.test.js +124 -0
- package/build/install/hosts/index.js +15 -0
- package/build/install/hosts/opencode.js +48 -0
- package/build/install/hosts/opencode.test.js +60 -0
- package/build/install/hosts/zed.js +37 -0
- package/build/install/hosts/zed.test.js +47 -0
- package/build/install/index.js +170 -0
- package/build/install/index.test.js +115 -0
- package/build/install/install.integration.test.js +103 -0
- package/build/install/types.js +1 -0
- package/build/multi-transport.test.js +1 -0
- package/build/plugin/mcp-plugin.js +84 -0
- package/build/real-environment/import-export.test.js +107 -0
- package/build/stdio-shutdown.test.js +28 -0
- package/build/tool-registry.test.js +57 -0
- package/build/tools/index.js +4 -0
- package/build/tools/save-document.js +5 -0
- package/build/tools/set-document-title.js +12 -0
- package/package.json +10 -6
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const BIN = join(__dirname, "..", "..", "build", "index.js");
|
|
9
|
+
// Sandbox every adapter's defaultPaths() resolution so a spawned `install`
|
|
10
|
+
// process can never resolve to the real developer's home directory.
|
|
11
|
+
// homedir() reads HOME on POSIX and USERPROFILE/HOMEDRIVE+HOMEPATH on
|
|
12
|
+
// Windows; claude-desktop's resolver reads APPDATA directly on win32.
|
|
13
|
+
function isolateEnv(homeDir) {
|
|
14
|
+
const env = { ...process.env };
|
|
15
|
+
env.HOME = homeDir;
|
|
16
|
+
env.USERPROFILE = homeDir;
|
|
17
|
+
env.APPDATA = join(homeDir, "AppData", "Roaming");
|
|
18
|
+
env.LOCALAPPDATA = join(homeDir, "AppData", "Local");
|
|
19
|
+
env.HOMEDRIVE = "C:"; // benign default; ignored on POSIX
|
|
20
|
+
env.HOMEPATH = homeDir;
|
|
21
|
+
delete env.XDG_CONFIG_HOME;
|
|
22
|
+
delete env.XDG_DATA_HOME;
|
|
23
|
+
delete env.XDG_STATE_HOME;
|
|
24
|
+
delete env.XDG_CACHE_HOME;
|
|
25
|
+
return env;
|
|
26
|
+
}
|
|
27
|
+
describe("install subcommand — end to end", () => {
|
|
28
|
+
let dir;
|
|
29
|
+
let home;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "drawio-e2e-"));
|
|
32
|
+
home = mkdtempSync(join(tmpdir(), "drawio-home-"));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
rmSync(home, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
function run(args) {
|
|
39
|
+
return spawnSync("node", [BIN, "install", ...args], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
env: isolateEnv(home),
|
|
42
|
+
cwd: home,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
it("codex --print emits TOML on stdout, no file written", () => {
|
|
46
|
+
const target = join(dir, "codex.toml");
|
|
47
|
+
const r = run(["codex", "--print", "--config-path", target]);
|
|
48
|
+
expect(r.status).toBe(0);
|
|
49
|
+
expect(r.stdout).toContain("[mcp_servers.drawio]");
|
|
50
|
+
expect(existsSync(target)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it("zed --print emits JSON", () => {
|
|
53
|
+
const target = join(dir, "zed.json");
|
|
54
|
+
const r = run(["zed", "--print", "--config-path", target]);
|
|
55
|
+
expect(r.status).toBe(0);
|
|
56
|
+
expect(r.stdout).toContain('"context_servers"');
|
|
57
|
+
});
|
|
58
|
+
it("codex writes to target when --yes passed", () => {
|
|
59
|
+
const target = join(dir, "codex.toml");
|
|
60
|
+
const r = run(["codex", "--yes", "--config-path", target]);
|
|
61
|
+
expect(r.status).toBe(0);
|
|
62
|
+
expect(existsSync(target)).toBe(true);
|
|
63
|
+
expect(readFileSync(target, "utf8")).toContain("[mcp_servers.drawio]");
|
|
64
|
+
});
|
|
65
|
+
it("codex refuses to overwrite non-empty target without --yes and returns exit 4", () => {
|
|
66
|
+
const target = join(dir, "codex.toml");
|
|
67
|
+
const first = run(["codex", "--yes", "--config-path", target]);
|
|
68
|
+
expect(first.status).toBe(0);
|
|
69
|
+
const modified = readFileSync(target, "utf8").replace("drawio-mcp-server", "drawio-mcp-server-old");
|
|
70
|
+
writeFileSync(target, modified);
|
|
71
|
+
const second = run(["codex", "--config-path", target]);
|
|
72
|
+
expect(second.status).toBe(4);
|
|
73
|
+
expect(second.stderr).toContain("Re-run with --yes");
|
|
74
|
+
});
|
|
75
|
+
it("all with per-host config paths writes to each, and never touches unoverridden adapters' real paths", () => {
|
|
76
|
+
const codexPath = join(dir, "codex.toml");
|
|
77
|
+
const zedPath = join(dir, "zed.json");
|
|
78
|
+
const r = run([
|
|
79
|
+
"all",
|
|
80
|
+
"--yes",
|
|
81
|
+
"--config-path",
|
|
82
|
+
`codex=${codexPath}`,
|
|
83
|
+
"--config-path",
|
|
84
|
+
`zed=${zedPath}`,
|
|
85
|
+
]);
|
|
86
|
+
expect(r.status).toBe(0);
|
|
87
|
+
expect(existsSync(codexPath)).toBe(true);
|
|
88
|
+
expect(existsSync(zedPath)).toBe(true);
|
|
89
|
+
// opencode, claude-desktop, and claude-code got no --config-path
|
|
90
|
+
// override. With HOME sandboxed to a fresh temp dir, their
|
|
91
|
+
// defaultPaths() resolve to non-existent files, so detect() reports
|
|
92
|
+
// "absent" and applyAll() skips them entirely — proving isolation
|
|
93
|
+
// rather than just asserting the explicit overrides worked.
|
|
94
|
+
expect(existsSync(join(home, ".claude.json"))).toBe(false);
|
|
95
|
+
expect(existsSync(join(home, ".config", "Claude", "claude_desktop_config.json"))).toBe(false);
|
|
96
|
+
expect(existsSync(join(home, ".config", "opencode", "opencode.json"))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it("returns exit 1 on unknown host", () => {
|
|
99
|
+
const r = run(["bogus"]);
|
|
100
|
+
expect(r.status).toBe(1);
|
|
101
|
+
expect(r.stderr).toContain("unknown host");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -138,6 +138,7 @@ describe("HTTP transport (stateless per-request)", () => {
|
|
|
138
138
|
await client.connect(transport);
|
|
139
139
|
const tools = await client.listTools();
|
|
140
140
|
expect(tools.tools.length).toBeGreaterThan(0);
|
|
141
|
+
expect(tools.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["set-document-title", "save-document"]));
|
|
141
142
|
await client.close();
|
|
142
143
|
});
|
|
143
144
|
it("handles multiple sequential HTTP client requests without reuse error", async () => {
|
|
@@ -1909,6 +1909,7 @@ var DrawMcp = (() => {
|
|
|
1909
1909
|
);
|
|
1910
1910
|
codec.decode(graphModelElement, model);
|
|
1911
1911
|
}
|
|
1912
|
+
sync_live_current_page_state(ui2);
|
|
1912
1913
|
return {
|
|
1913
1914
|
success: true,
|
|
1914
1915
|
message: `Diagram replaced successfully${filename ? ` from ${filename}` : ""}`,
|
|
@@ -2072,6 +2073,7 @@ var DrawMcp = (() => {
|
|
|
2072
2073
|
} finally {
|
|
2073
2074
|
model.endUpdate();
|
|
2074
2075
|
}
|
|
2076
|
+
sync_live_current_page_state(ui2);
|
|
2075
2077
|
return {
|
|
2076
2078
|
success: true,
|
|
2077
2079
|
message: `Diagram imported successfully (new page created)${filename ? `: ${filename}` : ""}`,
|
|
@@ -2481,6 +2483,78 @@ var DrawMcp = (() => {
|
|
|
2481
2483
|
}
|
|
2482
2484
|
}
|
|
2483
2485
|
|
|
2486
|
+
// src/tools/save-document/index.ts
|
|
2487
|
+
function normalize_optional_string2(value) {
|
|
2488
|
+
if (value === void 0 || value === null || value === "") {
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
return String(value);
|
|
2492
|
+
}
|
|
2493
|
+
var save_document = (ui2) => {
|
|
2494
|
+
const saveAction = ui2?.actions?.get?.("save");
|
|
2495
|
+
if (!saveAction || typeof saveAction.funct !== "function") {
|
|
2496
|
+
throw new Error("The Draw.io save action is not available");
|
|
2497
|
+
}
|
|
2498
|
+
if (typeof saveAction.isEnabled === "function" && saveAction.isEnabled() !== true) {
|
|
2499
|
+
throw new Error("The Draw.io save action is currently disabled");
|
|
2500
|
+
}
|
|
2501
|
+
saveAction.funct();
|
|
2502
|
+
const file = ui2?.getCurrentFile?.();
|
|
2503
|
+
return {
|
|
2504
|
+
triggered: true,
|
|
2505
|
+
title: normalize_optional_string2(file?.getTitle?.()),
|
|
2506
|
+
mode: normalize_optional_string2(file?.getMode?.())
|
|
2507
|
+
};
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
// src/tools/set-document-title/index.ts
|
|
2511
|
+
var DRAWIO_FILE_SUFFIX = /(\.drawio\.(?:svg|png)|\.drawio|\.xml|\.svg|\.png)$/i;
|
|
2512
|
+
function normalize_optional_string3(value) {
|
|
2513
|
+
if (value === void 0 || value === null || value === "") {
|
|
2514
|
+
return null;
|
|
2515
|
+
}
|
|
2516
|
+
return String(value);
|
|
2517
|
+
}
|
|
2518
|
+
function title_with_preserved_suffix(currentTitle, requestedTitle) {
|
|
2519
|
+
const title = normalize_optional_string3(requestedTitle)?.trim() ?? "";
|
|
2520
|
+
if (!title) {
|
|
2521
|
+
throw new Error("`title` must not be empty");
|
|
2522
|
+
}
|
|
2523
|
+
const currentSuffix = currentTitle?.match(DRAWIO_FILE_SUFFIX)?.[1] ?? "";
|
|
2524
|
+
if (!currentSuffix || DRAWIO_FILE_SUFFIX.test(title)) {
|
|
2525
|
+
return title;
|
|
2526
|
+
}
|
|
2527
|
+
return `${title}${currentSuffix}`;
|
|
2528
|
+
}
|
|
2529
|
+
function tool_error(error) {
|
|
2530
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
2531
|
+
}
|
|
2532
|
+
var set_document_title = (ui2, options) => {
|
|
2533
|
+
const file = ui2?.getCurrentFile?.();
|
|
2534
|
+
if (!file) {
|
|
2535
|
+
throw new Error("No active Draw.io file is available");
|
|
2536
|
+
}
|
|
2537
|
+
if (typeof file.rename !== "function") {
|
|
2538
|
+
throw new Error("Document renaming is not supported by this storage mode");
|
|
2539
|
+
}
|
|
2540
|
+
const previousTitle = normalize_optional_string3(file.getTitle?.());
|
|
2541
|
+
const nextTitle = title_with_preserved_suffix(previousTitle, options.title);
|
|
2542
|
+
return new Promise(
|
|
2543
|
+
(resolve, reject) => {
|
|
2544
|
+
file.rename?.(
|
|
2545
|
+
nextTitle,
|
|
2546
|
+
() => {
|
|
2547
|
+
resolve({
|
|
2548
|
+
previous_title: previousTitle,
|
|
2549
|
+
title: normalize_optional_string3(file.getTitle?.()) ?? nextTitle
|
|
2550
|
+
});
|
|
2551
|
+
},
|
|
2552
|
+
(error) => reject(tool_error(error))
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
);
|
|
2556
|
+
};
|
|
2557
|
+
|
|
2484
2558
|
// src/tool-registry.ts
|
|
2485
2559
|
var VISIBLE_PAGE_EXECUTION = {
|
|
2486
2560
|
mode: "visible-page"
|
|
@@ -2788,6 +2862,16 @@ var DrawMcp = (() => {
|
|
|
2788
2862
|
name: "rename-page",
|
|
2789
2863
|
params: /* @__PURE__ */ new Set(["page", "name"]),
|
|
2790
2864
|
handler: rename_page
|
|
2865
|
+
},
|
|
2866
|
+
{
|
|
2867
|
+
name: "set-document-title",
|
|
2868
|
+
params: /* @__PURE__ */ new Set(["title"]),
|
|
2869
|
+
handler: set_document_title
|
|
2870
|
+
},
|
|
2871
|
+
{
|
|
2872
|
+
name: "save-document",
|
|
2873
|
+
params: /* @__PURE__ */ new Set([]),
|
|
2874
|
+
handler: save_document
|
|
2791
2875
|
}
|
|
2792
2876
|
];
|
|
2793
2877
|
var toolDefinitions = rawToolDefinitions.map(
|
|
@@ -79,4 +79,111 @@ describe("real environment/import export", () => {
|
|
|
79
79
|
await expectNoBrowserErrors(context, "import-export");
|
|
80
80
|
await expectNoServerErrors(context, "import-export", logCountBefore);
|
|
81
81
|
}, 180000);
|
|
82
|
+
it("keeps ui.currentPage.root in sync with graph.model.root after replace import (regression for #56)", async () => {
|
|
83
|
+
await resetDiagram(context);
|
|
84
|
+
context.browserMessages.length = 0;
|
|
85
|
+
const logCountBefore = context.logger.entries.length;
|
|
86
|
+
const importXml = '<mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="regression-1" value="Regression cell" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>';
|
|
87
|
+
const { payload: importResult } = await callToolJson(context, "import-diagram", {
|
|
88
|
+
data: importXml,
|
|
89
|
+
format: "xml",
|
|
90
|
+
mode: "replace",
|
|
91
|
+
filename: "regression.drawio",
|
|
92
|
+
});
|
|
93
|
+
expectToolSuccess(importResult);
|
|
94
|
+
await context.page.waitForFunction(() => {
|
|
95
|
+
const maybeWindow = window;
|
|
96
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
97
|
+
const model = graph?.getModel?.();
|
|
98
|
+
const cells = Object.values(model?.cells ?? {});
|
|
99
|
+
return cells.some((cell) => cell?.value === "Regression cell");
|
|
100
|
+
});
|
|
101
|
+
const pageState = await context.page.evaluate(() => {
|
|
102
|
+
const maybeWindow = window;
|
|
103
|
+
const ui = maybeWindow.ui;
|
|
104
|
+
const page = ui?.currentPage;
|
|
105
|
+
const model = ui?.editor?.graph?.getModel?.();
|
|
106
|
+
const modelRoot = model?.getRoot?.() ?? model?.root ?? null;
|
|
107
|
+
return {
|
|
108
|
+
pageRootMatchesModelRoot: page?.root === modelRoot,
|
|
109
|
+
graphModelNodeIsNull: page?.graphModelNode == null,
|
|
110
|
+
pageRootHasImportedCell: (() => {
|
|
111
|
+
if (!page?.root || typeof page.root.getChildAt !== "function")
|
|
112
|
+
return null;
|
|
113
|
+
const rootChild = page.root.getChildAt(0);
|
|
114
|
+
if (!rootChild || typeof rootChild.getChildAt !== "function")
|
|
115
|
+
return null;
|
|
116
|
+
for (let i = 0; i < (rootChild.getChildCount?.() ?? 0); i++) {
|
|
117
|
+
const child = rootChild.getChildAt(i);
|
|
118
|
+
if (child?.value === "Regression cell")
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
})(),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
expect(pageState.pageRootMatchesModelRoot).toBe(true);
|
|
126
|
+
expect(pageState.graphModelNodeIsNull).toBe(true);
|
|
127
|
+
expect(pageState.pageRootHasImportedCell).toBe(true);
|
|
128
|
+
// The page-root identity asserted above is a proxy for correctness. What a
|
|
129
|
+
// user actually loses when currentPage goes stale is the *serialized* file:
|
|
130
|
+
// drawio serializes through the Page abstraction, so a stale page root ends
|
|
131
|
+
// up in the saved bytes while the live graph (holding the import) does not.
|
|
132
|
+
// Assert the import survives serialization, not just the in-memory model.
|
|
133
|
+
const serializedFileData = await context.page.evaluate(() => {
|
|
134
|
+
const maybeWindow = window;
|
|
135
|
+
const ui = maybeWindow.ui;
|
|
136
|
+
try {
|
|
137
|
+
if (typeof ui?.getXmlFileData === "function" &&
|
|
138
|
+
typeof maybeWindow.mxUtils?.getXml === "function") {
|
|
139
|
+
return String(maybeWindow.mxUtils.getXml(ui.getXmlFileData(true, false, true)));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* fall through to getFileData */
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (typeof ui?.getFileData === "function") {
|
|
147
|
+
return String(ui.getFileData(true, null, null, null, true, null, null, null, null, true));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* unsupported build */
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
});
|
|
155
|
+
if (serializedFileData !== null) {
|
|
156
|
+
expect(serializedFileData).toContain("Regression cell");
|
|
157
|
+
}
|
|
158
|
+
const survivesPageRoundTrip = await context.page.evaluate(async () => {
|
|
159
|
+
const maybeWindow = window;
|
|
160
|
+
const ui = maybeWindow.ui;
|
|
161
|
+
if (!ui?.insertPage || !ui?.selectPage || !ui?.currentPage) {
|
|
162
|
+
return { skipped: true };
|
|
163
|
+
}
|
|
164
|
+
const originalPage = ui.currentPage;
|
|
165
|
+
const scratch = ui.insertPage();
|
|
166
|
+
ui.selectPage(scratch);
|
|
167
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
168
|
+
ui.selectPage(originalPage);
|
|
169
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
170
|
+
const model = ui.editor.graph.getModel();
|
|
171
|
+
const cells = Object.values(model?.cells ?? {});
|
|
172
|
+
const stillPresent = cells.some((cell) => cell?.value === "Regression cell");
|
|
173
|
+
if (typeof ui.removePage === "function") {
|
|
174
|
+
try {
|
|
175
|
+
ui.removePage(scratch);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* best-effort cleanup */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { skipped: false, stillPresent };
|
|
182
|
+
});
|
|
183
|
+
if (!survivesPageRoundTrip.skipped) {
|
|
184
|
+
expect(survivesPageRoundTrip.stillPresent).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
await expectNoBrowserErrors(context, "import-export");
|
|
187
|
+
await expectNoServerErrors(context, "import-export", logCountBefore);
|
|
188
|
+
}, 180000);
|
|
82
189
|
});
|
|
@@ -134,6 +134,34 @@ describe("graceful shutdown releases WebSocket port", () => {
|
|
|
134
134
|
const free = await isPortFree(extensionPort, HOST);
|
|
135
135
|
expect(free).toBe(true);
|
|
136
136
|
}, 20000);
|
|
137
|
+
it("keeps the HTTP port free in stdio-only mode", async () => {
|
|
138
|
+
const extensionPort = await getFreePort();
|
|
139
|
+
const httpPort = await getFreePort();
|
|
140
|
+
proc = spawnServer(extensionPort, httpPort);
|
|
141
|
+
const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
|
|
142
|
+
expect(portTaken).toBe(true);
|
|
143
|
+
// Give any (wrongly) eager HTTP listener time to bind before asserting.
|
|
144
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
145
|
+
expect(await isPortFree(httpPort, HOST)).toBe(true);
|
|
146
|
+
}, 20000);
|
|
147
|
+
it("exits fatally when the HTTP port cannot be bound in http transport", async () => {
|
|
148
|
+
const extensionPort = await getFreePort();
|
|
149
|
+
// Occupy the HTTP port so the server's bind attempt fails.
|
|
150
|
+
const blocker = createServer();
|
|
151
|
+
await new Promise((resolve) => {
|
|
152
|
+
blocker.listen({ port: 0, host: HOST }, () => resolve());
|
|
153
|
+
});
|
|
154
|
+
const takenPort = blocker.address().port;
|
|
155
|
+
proc = spawnServer(extensionPort, takenPort, "http");
|
|
156
|
+
let stderr = "";
|
|
157
|
+
proc.stderr.on("data", (chunk) => {
|
|
158
|
+
stderr += chunk.toString();
|
|
159
|
+
});
|
|
160
|
+
const exitCode = await waitForExit(proc, 10000);
|
|
161
|
+
expect(exitCode).toBe(1);
|
|
162
|
+
expect(stderr).toContain("Failed to bind HTTP server");
|
|
163
|
+
blocker.close();
|
|
164
|
+
}, 20000);
|
|
137
165
|
it("releases extension port when stdio host closes stdin pipe", async () => {
|
|
138
166
|
const extensionPort = await getFreePort();
|
|
139
167
|
const httpPort = await getFreePort();
|
|
@@ -20,6 +20,8 @@ describe("shared tool registry", () => {
|
|
|
20
20
|
"create-page",
|
|
21
21
|
"copy-page",
|
|
22
22
|
"rename-page",
|
|
23
|
+
"set-document-title",
|
|
24
|
+
"save-document",
|
|
23
25
|
"import-mermaid",
|
|
24
26
|
]));
|
|
25
27
|
});
|
|
@@ -38,6 +40,9 @@ describe("shared tool registry", () => {
|
|
|
38
40
|
expect(registry.get("list-paged-model")?.params.has("target_document")).toBe(true);
|
|
39
41
|
expect(registry.get("rename-page")?.params.has("page")).toBe(true);
|
|
40
42
|
expect(registry.get("rename-page")?.params.has("target_document")).toBe(true);
|
|
43
|
+
expect(registry.get("set-document-title")?.params.has("title")).toBe(true);
|
|
44
|
+
expect(registry.get("set-document-title")?.params.has("target_document")).toBe(true);
|
|
45
|
+
expect(registry.get("save-document")?.params.has("target_document")).toBe(true);
|
|
41
46
|
expect(registry.get("copy-page")?.params.has("page")).toBe(true);
|
|
42
47
|
expect(registry.get("copy-page")?.params.has("target_document")).toBe(true);
|
|
43
48
|
expect(registry.get("import-mermaid")?.params.has("target_page")).toBe(true);
|
|
@@ -45,6 +50,58 @@ describe("shared tool registry", () => {
|
|
|
45
50
|
expect(registry.get("get-shape-by-name")?.params.has("target_page")).toBe(false);
|
|
46
51
|
expect(registry.get("get-shape-by-name")?.params.has("target_document")).toBe(true);
|
|
47
52
|
});
|
|
53
|
+
it("renames the current document while preserving its file extension", async () => {
|
|
54
|
+
await activateDocument();
|
|
55
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
56
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
57
|
+
const handler = registry.get("set-document-title")?.handler;
|
|
58
|
+
expect(handler).toBeDefined();
|
|
59
|
+
let title = "Untitled Diagram.drawio";
|
|
60
|
+
const rename = jest.fn((nextTitle, success, _error) => {
|
|
61
|
+
title = nextTitle;
|
|
62
|
+
success();
|
|
63
|
+
});
|
|
64
|
+
const ui = {
|
|
65
|
+
getCurrentFile: jest.fn(() => ({
|
|
66
|
+
getTitle: () => title,
|
|
67
|
+
rename,
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
await expect(handler?.(ui, {
|
|
71
|
+
target_document: { id: "doc-1" },
|
|
72
|
+
title: "Architecture",
|
|
73
|
+
})).resolves.toEqual({
|
|
74
|
+
previous_title: "Untitled Diagram.drawio",
|
|
75
|
+
title: "Architecture.drawio",
|
|
76
|
+
});
|
|
77
|
+
expect(rename).toHaveBeenCalledWith("Architecture.drawio", expect.any(Function), expect.any(Function));
|
|
78
|
+
});
|
|
79
|
+
it("triggers the editor's existing save action", async () => {
|
|
80
|
+
await activateDocument();
|
|
81
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
82
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
83
|
+
const handler = registry.get("save-document")?.handler;
|
|
84
|
+
expect(handler).toBeDefined();
|
|
85
|
+
const save = jest.fn();
|
|
86
|
+
const ui = {
|
|
87
|
+
actions: {
|
|
88
|
+
get: jest.fn((name) => name === "save" ? { funct: save } : null),
|
|
89
|
+
},
|
|
90
|
+
getCurrentFile: jest.fn(() => ({
|
|
91
|
+
getTitle: () => "Architecture.drawio",
|
|
92
|
+
getMode: () => "device",
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
expect(handler?.(ui, {
|
|
96
|
+
target_document: { id: "doc-1" },
|
|
97
|
+
})).toEqual({
|
|
98
|
+
triggered: true,
|
|
99
|
+
title: "Architecture.drawio",
|
|
100
|
+
mode: "device",
|
|
101
|
+
});
|
|
102
|
+
expect(ui.actions.get).toHaveBeenCalledWith("save");
|
|
103
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
48
105
|
it("classifies background-safe and UI-bound page tools in the shared registry", async () => {
|
|
49
106
|
const toolDefinitions = await loadToolDefinitions();
|
|
50
107
|
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
package/build/tools/index.js
CHANGED
|
@@ -22,10 +22,12 @@ import { registerListPagesTool } from "./list-pages.js";
|
|
|
22
22
|
import { registerListPagedModelTool } from "./list-paged-model.js";
|
|
23
23
|
import { registerMoveCellToLayerTool } from "./move-cell-to-layer.js";
|
|
24
24
|
import { registerRenamePageTool } from "./rename-page.js";
|
|
25
|
+
import { registerSaveDocumentTool } from "./save-document.js";
|
|
25
26
|
import { registerSetActiveLayerTool } from "./set-active-layer.js";
|
|
26
27
|
import { registerSetCellDataTool } from "./set-cell-data.js";
|
|
27
28
|
import { registerSetCellParentTool } from "./set-cell-parent.js";
|
|
28
29
|
import { registerSetCellShapeTool } from "./set-cell-shape.js";
|
|
30
|
+
import { registerSetDocumentTitleTool } from "./set-document-title.js";
|
|
29
31
|
const registrars = [
|
|
30
32
|
registerGetSelectedCellTool,
|
|
31
33
|
registerAddRectangleTool,
|
|
@@ -55,6 +57,8 @@ const registrars = [
|
|
|
55
57
|
registerCreatePageTool,
|
|
56
58
|
registerCopyPageTool,
|
|
57
59
|
registerRenamePageTool,
|
|
60
|
+
registerSetDocumentTitleTool,
|
|
61
|
+
registerSaveDocumentTool,
|
|
58
62
|
];
|
|
59
63
|
export function registerTools(...args) {
|
|
60
64
|
for (const register of registrars) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { default_tool } from "../tool.js";
|
|
2
|
+
export const TOOL_save_document = "save-document";
|
|
3
|
+
export const registerSaveDocumentTool = (server, context) => {
|
|
4
|
+
server.tool(TOOL_save_document, "Triggers Draw.io's existing File > Save action for the current document. The active storage provider may display an authentication, conflict, or Save As prompt that requires user interaction.", {}, default_tool(TOOL_save_document, context, { queue: true }));
|
|
5
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { default_tool } from "../tool.js";
|
|
3
|
+
export const TOOL_set_document_title = "set-document-title";
|
|
4
|
+
export const registerSetDocumentTitleTool = (server, context) => {
|
|
5
|
+
server.tool(TOOL_set_document_title, "Renames the current Draw.io document through its active storage provider while preserving the existing file extension when one is present.", {
|
|
6
|
+
title: z
|
|
7
|
+
.string()
|
|
8
|
+
.trim()
|
|
9
|
+
.min(1)
|
|
10
|
+
.describe("New document title, with or without the existing extension"),
|
|
11
|
+
}, default_tool(TOOL_set_document_title, context, { queue: true }));
|
|
12
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drawio-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Provides Draw.io services to MCP Clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"url": "git+https://github.com/lgazo/drawio-mcp-server.git"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@hono/node-server": "1.19.
|
|
35
|
-
"@modelcontextprotocol/sdk": "1.
|
|
34
|
+
"@hono/node-server": "1.19.17",
|
|
35
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
36
36
|
"cachedir": "2.4.0",
|
|
37
|
-
"hono": "4.
|
|
38
|
-
"nanoid": "5.1.
|
|
37
|
+
"hono": "4.13.5",
|
|
38
|
+
"nanoid": "5.1.16",
|
|
39
39
|
"node-forge": "1.4.0",
|
|
40
40
|
"unzipper": "0.12.3",
|
|
41
41
|
"ws": "8.21.0",
|
|
@@ -45,22 +45,26 @@
|
|
|
45
45
|
"@biomejs/biome": "2.4.13",
|
|
46
46
|
"@jest/globals": "30.2.0",
|
|
47
47
|
"@playwright/test": "1.59.1",
|
|
48
|
+
"@types/diff": "8.0.0",
|
|
48
49
|
"@types/jest": "30.0.0",
|
|
49
50
|
"@types/node": "25.0.3",
|
|
50
51
|
"@types/node-forge": "1.3.11",
|
|
51
52
|
"@types/unzipper": "0.10.11",
|
|
52
53
|
"@types/ws": "8.18.1",
|
|
53
54
|
"concurrently": "9.1.2",
|
|
55
|
+
"diff": "8.0.4",
|
|
54
56
|
"esbuild": "0.25.12",
|
|
55
57
|
"globals": "16.5.0",
|
|
56
58
|
"jest": "30.2.0",
|
|
57
59
|
"jest-environment-node": "30.2.0",
|
|
60
|
+
"jsonc-parser": "3.3.1",
|
|
58
61
|
"prettier": "3.7.4",
|
|
59
62
|
"rimraf": "6.1.3",
|
|
63
|
+
"smol-toml": "1.8.0",
|
|
60
64
|
"ts-jest": "29.4.9",
|
|
61
65
|
"typescript": "5.9.3",
|
|
62
66
|
"drawio-mcp-dev-proxy": "1.0.0",
|
|
63
|
-
"drawio-mcp-plugin": "2.
|
|
67
|
+
"drawio-mcp-plugin": "2.3.0"
|
|
64
68
|
},
|
|
65
69
|
"scripts": {
|
|
66
70
|
"vendor:compat": "rimraf src/vendored/compat && mkdir -p src/vendored/compat && cp ../drawio-mcp-compat/src/index.ts src/vendored/compat/index.ts",
|