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 CHANGED
@@ -9,12 +9,16 @@ Let's do some Vibe Diagramming with the most wide-spread diagramming tool called
9
9
 
10
10
  ## Key Highlights
11
11
 
12
- - Enable Draw.io MCP in IFrames ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
13
- - AWS, GCP, Azure, Cisco19, and CiscoSafe stencils auto-discovered at runtime from drawio's sidebar ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
14
- - Multi-document targeting with `list-documents` and `target_document` selectors for multi-tab workflows ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
15
- - Multi-page targeting with required `target_page` selectors for page-scoped tools ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
16
- - Per-document FIFO serialization for live operations, so multiple agents can work on different files safely ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
17
- - Page management tools: `list-pages`, `get-current-page`, `create-page`, `copy-page`, `rename-page` ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
12
+ - Claude Code plugin: `/plugin marketplace add lgazo/drawio-mcp-server` then `/plugin install drawio`, with `/drawio-open` and `/drawio-status` slash commands ![v2.3.0](https://img.shields.io/badge/v2.3.0-blue)
13
+ - `npx drawio-mcp-server install <host>` writes the MCP config for `claude-code`, `claude-desktop`, `codex`, `zed`, `opencode`, or `all` ![v2.3.0](https://img.shields.io/badge/v2.3.0-blue)
14
+ - Document persistence tools: `set-document-title` and `save-document` ![v2.3.0](https://img.shields.io/badge/v2.3.0-blue)
15
+ - Draw.io library API version compatibility mechanism ![v2.2.0](https://img.shields.io/badge/v2.2.0-blue)
16
+ - Enable Draw.io MCP in IFrames
17
+ - AWS, GCP, Azure, Cisco19, and CiscoSafe stencils auto-discovered at runtime from drawio's sidebar
18
+ - Multi-document targeting with `list-documents` and `target_document` selectors for multi-tab workflows
19
+ - Multi-page targeting with required `target_page` selectors for page-scoped tools
20
+ - Per-document FIFO serialization for live operations, so multiple agents can work on different files safely
21
+ - Page management tools: `list-pages`, `get-current-page`, `create-page`, `copy-page`, `rename-page`
18
22
  - Import, embed, or expand [Mermaid](https://mermaid.js.org/) diagrams ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
19
23
  - Firefox support is back, TLS mode is necessary ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
20
24
  - Server supports TLS mode and optionally generates self-signed certificates ![v2.1.0](https://img.shields.io/badge/v2.1.0-blue)
@@ -58,10 +62,15 @@ No additional requirements - runs out of the box with `--editor` flag.
58
62
 
59
63
  ### 1. Configure your MCP host
60
64
 
61
- Add the server to your MCP client configuration:
65
+ **Fastest path:**
66
+
67
+ - **Claude Code:** `/plugin marketplace add lgazo/drawio-mcp-server` then `/plugin install drawio`.
68
+ - **Any other host:** `npx drawio-mcp-server install <host>` where `<host>` is `claude-code`, `claude-desktop`, `codex`, `zed`, `opencode`, or `all`.
69
+
70
+ See [docs/PLUGINS.md](./docs/PLUGINS.md) for the full reference, flags, and uninstall, and [docs/release.md](./docs/release.md) for how releases are cut.
62
71
 
63
72
  <details>
64
- <summary>Claude Desktop</summary>
73
+ <summary>Manual install (JSON snippet, Claude Desktop example)</summary>
65
74
 
66
75
  Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
67
76
 
@@ -75,63 +84,7 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
75
84
  }
76
85
  }
77
86
  ```
78
- </details>
79
-
80
- <details>
81
- <summary>Claude Code</summary>
82
-
83
- ```sh
84
- claude mcp add-json drawio '{"type":"stdio","command":"npx","args":["-y","drawio-mcp-server","--editor"]}'
85
- ```
86
- </details>
87
-
88
- <details>
89
- <summary>Zed</summary>
90
-
91
- Add to `~/.config/zed/settings.json`:
92
-
93
- ```json
94
- {
95
- "context_servers": {
96
- "drawio": {
97
- "command": "npx",
98
- "args": ["-y", "drawio-mcp-server", "--editor"],
99
- "env": {}
100
- }
101
- }
102
- }
103
- ```
104
- </details>
105
-
106
- <details>
107
- <summary>Codex</summary>
108
-
109
- Edit `~/.codex/config.toml`:
110
-
111
- ```toml
112
- [mcp_servers.drawio]
113
- command = "npx"
114
- args = ["-y", "drawio-mcp-server", "--editor"]
115
- ```
116
- </details>
117
87
 
118
- <details>
119
- <summary>OpenCode</summary>
120
-
121
- Add to `opencode.json` in your project root or `~/.config/opencode/opencode.json`:
122
-
123
- ```jsonc
124
- {
125
- "$schema": "https://opencode.ai/config.json",
126
- "mcp": {
127
- "drawio": {
128
- "type": "local",
129
- "command": ["npx", "-y", "drawio-mcp-server", "--editor"],
130
- "enabled": true
131
- }
132
- }
133
- }
134
- ```
135
88
  </details>
136
89
 
137
90
  For other MCP clients and detailed configuration (including pnpm options), see [Configuration](./CONFIG.md).
@@ -223,11 +176,11 @@ See [DESKTOP.md](./DESKTOP.md) for the full setup steps and the current limitati
223
176
 
224
177
  ## Star History
225
178
 
226
- <a href="https://star-history.com/#lgazo/drawio-mcp-server&Date">
179
+ <a href="https://star-history.dera.page/#lgazo/drawio-mcp-server&type=Date">
227
180
  <picture>
228
- <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=lgazo/drawio-mcp-server&type=Date&theme=dark" />
229
- <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=lgazo/drawio-mcp-server&type=Date" />
230
- <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=lgazo/drawio-mcp-server&type=Date" />
181
+ <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date&theme=dark" />
182
+ <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date" />
183
+ <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=lgazo/drawio-mcp-server&type=Date" />
231
184
  </picture>
232
185
  </a>
233
186
 
package/build/index.js CHANGED
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { readFileSync, existsSync, statSync, readdirSync, realpathSync, } from "node:fs";
14
14
  import { WebSocket, WebSocketServer } from "ws";
15
- const VERSION = process.env.npm_package_version ?? "2.2.0";
15
+ const VERSION = process.env.npm_package_version ?? "2.3.0";
16
16
  import { buildConfig, defaultConfig, hasFlag, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
17
17
  import { installDesktopPlugin } from "./install-desktop-plugin.js";
18
18
  import { bus_reply_stream, bus_request_stream, } from "./types.js";
@@ -28,6 +28,17 @@ import { target_document_field } from "./tools/shared.js";
28
28
  import { resolveTlsMaterial } from "./tls/index.js";
29
29
  import { handleCompatReport } from "./drawio-compat/log-report.js";
30
30
  const fatalLog = create_console_logger();
31
+ if (process.argv[2] === "install") {
32
+ const { runInstall } = await import("./install/index.js");
33
+ try {
34
+ const code = await runInstall(process.argv.slice(3));
35
+ process.exit(code);
36
+ }
37
+ catch (err) {
38
+ fatalLog.log("error", err instanceof Error ? err.message : String(err));
39
+ process.exit(1);
40
+ }
41
+ }
31
42
  /**
32
43
  * Display help message and exit
33
44
  */
@@ -223,6 +234,20 @@ async function startHttpServer(createServer, disposeMcpServer, log, httpPort, co
223
234
  }
224
235
  : {}),
225
236
  });
237
+ // `serve()` calls `listen()` synchronously; wait for the outcome so a failed
238
+ // bind surfaces as a clean, fatal error instead of an unhandled 'error'
239
+ // event (or, worse, silence).
240
+ await new Promise((resolve, reject) => {
241
+ httpServer.once("error", (error) => {
242
+ reject(new Error(`Failed to bind HTTP server on port ${httpPort}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
243
+ });
244
+ if (httpServer.listening) {
245
+ resolve();
246
+ }
247
+ else {
248
+ httpServer.once("listening", () => resolve());
249
+ }
250
+ });
226
251
  const listeningPort = httpPort === 0
227
252
  ? (httpServer.address()?.port ?? httpPort)
228
253
  : httpPort;
@@ -877,7 +902,21 @@ async function main() {
877
902
  void shutdown("stdin-close");
878
903
  });
879
904
  }
880
- await app.startHttpServer(config.httpPort, config, features);
905
+ // Only open the HTTP listener when something will actually be served on
906
+ // it. A stdio-only run previously bound port 3000 unconditionally,
907
+ // colliding with common dev servers while serving nothing useful
908
+ // (see upstream issue #66).
909
+ if (features.enableMcp || features.enableEditor) {
910
+ await app.startHttpServer(config.httpPort, config, features);
911
+ }
912
+ else {
913
+ app.log.debug("HTTP server not started: no HTTP surface enabled (stdio-only run). Use --transport http or --editor to serve HTTP.");
914
+ const httpPortExplicit = cliArgs.includes("--http-port") ||
915
+ Boolean(process.env.DRAWIO_MCP_HTTP_PORT?.length);
916
+ if (httpPortExplicit) {
917
+ app.log.log("warning", `--http-port was set but no HTTP surface is enabled; port ${config.httpPort} stays free. Use --transport http or --editor to serve HTTP.`);
918
+ }
919
+ }
881
920
  app.log.debug(`Draw.io MCP Server running on ${config.transports}`);
882
921
  }
883
922
  const isMainModule = process.argv[1]
@@ -0,0 +1,21 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, basename, join } from "node:path";
4
+ import { createPatch } from "diff";
5
+ export async function atomicWrite(target, contents) {
6
+ const dir = dirname(target);
7
+ const tmp = join(dir, `.${basename(target)}.${process.pid}.${Date.now()}.tmp`);
8
+ await fs.writeFile(tmp, contents, "utf8");
9
+ await fs.rename(tmp, target);
10
+ }
11
+ export async function ensureBackup(target) {
12
+ if (!existsSync(target))
13
+ return null;
14
+ const bak = `${target}.bak-${Date.now()}`;
15
+ const contents = await fs.readFile(target, "utf8");
16
+ await fs.writeFile(bak, contents, "utf8");
17
+ return bak;
18
+ }
19
+ export function unifiedDiff(oldText, newText, label) {
20
+ return createPatch(label, oldText, newText, "", "", { context: 3 });
21
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import { mkdtempSync, readFileSync, writeFileSync, rmSync, readdirSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { atomicWrite, ensureBackup, unifiedDiff } from "./config-io.js";
6
+ describe("config-io", () => {
7
+ let dir;
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), "drawio-install-"));
10
+ });
11
+ afterEach(() => {
12
+ rmSync(dir, { recursive: true, force: true });
13
+ });
14
+ it("atomicWrite writes contents and leaves no tmp file", async () => {
15
+ const target = join(dir, "cfg.json");
16
+ await atomicWrite(target, '{\n "a": 1\n}\n');
17
+ expect(readFileSync(target, "utf8")).toBe('{\n "a": 1\n}\n');
18
+ expect(readdirSync(dir).filter((f) => f.startsWith(".cfg.json."))).toEqual([]);
19
+ });
20
+ it("ensureBackup copies once and returns backup path", async () => {
21
+ const target = join(dir, "cfg.json");
22
+ writeFileSync(target, "orig");
23
+ const bak = await ensureBackup(target);
24
+ expect(bak).toMatch(/cfg\.json\.bak-\d+$/);
25
+ expect(readFileSync(bak, "utf8")).toBe("orig");
26
+ });
27
+ it("ensureBackup returns null when file missing", async () => {
28
+ expect(await ensureBackup(join(dir, "nope.json"))).toBeNull();
29
+ });
30
+ it("unifiedDiff produces a diff header referencing the label", () => {
31
+ const d = unifiedDiff("a\n", "b\n", "cfg.json");
32
+ expect(d).toContain("--- cfg.json");
33
+ expect(d).toContain("+++ cfg.json");
34
+ expect(d).toContain("-a");
35
+ expect(d).toContain("+b");
36
+ });
37
+ });
@@ -0,0 +1,37 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { applyEdits, modify } from "jsonc-parser";
5
+ export const claudeCodeAdapter = {
6
+ id: "claude-code",
7
+ displayName: "Claude Code (user scope)",
8
+ defaultPaths() {
9
+ return [join(homedir(), ".claude.json")];
10
+ },
11
+ async detect() {
12
+ return existsSync(claudeCodeAdapter.defaultPaths()[0])
13
+ ? "installed"
14
+ : "absent";
15
+ },
16
+ async read(path) {
17
+ return existsSync(path) ? fs.readFile(path, "utf8") : "";
18
+ },
19
+ merge(source, entry, name, { uninstall }) {
20
+ const text = source.trim() === "" ? "{}\n" : source;
21
+ const value = uninstall
22
+ ? undefined
23
+ : {
24
+ type: entry.transport,
25
+ command: entry.command,
26
+ args: entry.args,
27
+ ...(Object.keys(entry.env).length ? { env: entry.env } : {}),
28
+ };
29
+ const edits = modify(text, ["mcpServers", name], value, {
30
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
31
+ });
32
+ return applyEdits(text, edits);
33
+ },
34
+ diffLabel() {
35
+ return claudeCodeAdapter.defaultPaths()[0];
36
+ },
37
+ };
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { claudeCodeAdapter } from "./claude-code.js";
3
+ const ENTRY = {
4
+ command: "npx",
5
+ args: ["-y", "drawio-mcp-server", "--editor"],
6
+ env: {},
7
+ transport: "stdio",
8
+ };
9
+ describe("claudeCodeAdapter.merge", () => {
10
+ it("adds mcpServers.drawio to a fresh file", () => {
11
+ const out = claudeCodeAdapter.merge("", ENTRY, "drawio", {
12
+ uninstall: false,
13
+ });
14
+ const parsed = JSON.parse(out);
15
+ expect(parsed.mcpServers.drawio).toEqual({
16
+ type: "stdio",
17
+ command: "npx",
18
+ args: ["-y", "drawio-mcp-server", "--editor"],
19
+ });
20
+ });
21
+ it("uninstall removes only that key", () => {
22
+ const source = JSON.stringify({
23
+ mcpServers: {
24
+ drawio: { type: "stdio", command: "old" },
25
+ other: { command: "x" },
26
+ },
27
+ });
28
+ const out = claudeCodeAdapter.merge(source, ENTRY, "drawio", {
29
+ uninstall: true,
30
+ });
31
+ const parsed = JSON.parse(out);
32
+ expect(parsed.mcpServers.drawio).toBeUndefined();
33
+ expect(parsed.mcpServers.other).toEqual({ command: "x" });
34
+ });
35
+ it("preserves unrelated top-level keys", () => {
36
+ const source = JSON.stringify({ projects: { "/tmp": {} }, mcpServers: {} }, null, 2);
37
+ const out = claudeCodeAdapter.merge(source, ENTRY, "drawio", {
38
+ uninstall: false,
39
+ });
40
+ expect(JSON.parse(out).projects).toEqual({ "/tmp": {} });
41
+ });
42
+ });
43
+ describe("claudeCodeAdapter.defaultPaths", () => {
44
+ it("points at ~/.claude.json", () => {
45
+ expect(claudeCodeAdapter.defaultPaths()[0]).toMatch(/\.claude\.json$/);
46
+ });
47
+ });
@@ -0,0 +1,45 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { homedir, platform } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { applyEdits, modify } from "jsonc-parser";
5
+ export function resolveClaudeDesktopPath(plat, home, env) {
6
+ if (plat === "darwin")
7
+ return posix.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
8
+ if (plat === "win32") {
9
+ const appdata = env.APPDATA ?? win32.join(home, "AppData", "Roaming");
10
+ return win32.join(appdata, "Claude", "claude_desktop_config.json");
11
+ }
12
+ return posix.join(home, ".config", "Claude", "claude_desktop_config.json");
13
+ }
14
+ export const claudeDesktopAdapter = {
15
+ id: "claude-desktop",
16
+ displayName: "Claude Desktop",
17
+ defaultPaths() {
18
+ return [resolveClaudeDesktopPath(platform(), homedir(), process.env)];
19
+ },
20
+ async detect() {
21
+ return existsSync(claudeDesktopAdapter.defaultPaths()[0])
22
+ ? "installed"
23
+ : "absent";
24
+ },
25
+ async read(path) {
26
+ return existsSync(path) ? fs.readFile(path, "utf8") : "";
27
+ },
28
+ merge(source, entry, name, { uninstall }) {
29
+ const text = source.trim() === "" ? "{}\n" : source;
30
+ const value = uninstall
31
+ ? undefined
32
+ : {
33
+ command: entry.command,
34
+ args: entry.args,
35
+ ...(Object.keys(entry.env).length ? { env: entry.env } : {}),
36
+ };
37
+ const edits = modify(text, ["mcpServers", name], value, {
38
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
39
+ });
40
+ return applyEdits(text, edits);
41
+ },
42
+ diffLabel() {
43
+ return claudeDesktopAdapter.defaultPaths()[0];
44
+ },
45
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import { claudeDesktopAdapter, resolveClaudeDesktopPath, } from "./claude-desktop.js";
3
+ const ENTRY = {
4
+ command: "npx",
5
+ args: ["-y", "drawio-mcp-server", "--editor"],
6
+ env: {},
7
+ transport: "stdio",
8
+ };
9
+ describe("claudeDesktopAdapter.merge", () => {
10
+ it("adds mcpServers.drawio", () => {
11
+ const out = claudeDesktopAdapter.merge("", ENTRY, "drawio", {
12
+ uninstall: false,
13
+ });
14
+ const parsed = JSON.parse(out);
15
+ expect(parsed.mcpServers.drawio).toEqual({
16
+ command: "npx",
17
+ args: ["-y", "drawio-mcp-server", "--editor"],
18
+ });
19
+ });
20
+ it("preserves comments in jsonc source", () => {
21
+ const source = [
22
+ "{",
23
+ " // pinned by me",
24
+ ' "mcpServers": {',
25
+ ' "other": { "command": "x" }',
26
+ " }",
27
+ "}",
28
+ ].join("\n");
29
+ const out = claudeDesktopAdapter.merge(source, ENTRY, "drawio", {
30
+ uninstall: false,
31
+ });
32
+ expect(out).toContain("// pinned by me");
33
+ expect(out).toContain('"drawio"');
34
+ });
35
+ it("uninstall removes the entry", () => {
36
+ const source = JSON.stringify({
37
+ mcpServers: { drawio: { command: "old" } },
38
+ });
39
+ const out = claudeDesktopAdapter.merge(source, ENTRY, "drawio", {
40
+ uninstall: true,
41
+ });
42
+ expect(JSON.parse(out).mcpServers).toEqual({});
43
+ });
44
+ });
45
+ describe("resolveClaudeDesktopPath", () => {
46
+ it("darwin uses Application Support", () => {
47
+ expect(resolveClaudeDesktopPath("darwin", "/Users/me", {})).toMatch(/Library\/Application Support\/Claude\/claude_desktop_config\.json$/);
48
+ });
49
+ it("win32 uses APPDATA when set", () => {
50
+ expect(resolveClaudeDesktopPath("win32", "C:\\Users\\me", {
51
+ APPDATA: "C:\\Users\\me\\AppData\\Roaming",
52
+ })).toMatch(/AppData\\Roaming\\Claude\\claude_desktop_config\.json$/);
53
+ });
54
+ it("linux uses ~/.config/Claude", () => {
55
+ expect(resolveClaudeDesktopPath("linux", "/home/me", {})).toBe("/home/me/.config/Claude/claude_desktop_config.json");
56
+ });
57
+ });
@@ -0,0 +1,171 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { stringify } from "smol-toml";
6
+ export const codexAdapter = {
7
+ id: "codex",
8
+ displayName: "Codex CLI",
9
+ defaultPaths() {
10
+ return [join(homedir(), ".codex", "config.toml")];
11
+ },
12
+ async detect() {
13
+ return existsSync(codexAdapter.defaultPaths()[0]) ? "installed" : "absent";
14
+ },
15
+ async read(path) {
16
+ if (!existsSync(path))
17
+ return "";
18
+ return fs.readFile(path, "utf8");
19
+ },
20
+ merge(source, entry, name, { uninstall }) {
21
+ return mergeTomlBlock(source, name, uninstall ? null : blockBody(entry));
22
+ },
23
+ diffLabel() {
24
+ return codexAdapter.defaultPaths()[0];
25
+ },
26
+ };
27
+ function blockBody(entry) {
28
+ const table = {
29
+ command: entry.command,
30
+ args: entry.args,
31
+ };
32
+ // `smol-toml` renders arrays/inline-tables with padding spaces
33
+ // (`[ "a", "b" ]`); normalize to the compact style used elsewhere in this
34
+ // project's generated configs (`["a", "b"]`).
35
+ const base = stringify(table)
36
+ .trim()
37
+ .replace(/\[ /g, "[")
38
+ .replace(/ \]/g, "]");
39
+ const envKeys = Object.keys(entry.env);
40
+ if (envKeys.length === 0)
41
+ return base;
42
+ // `smol-toml`'s `stringify` renders a nested object as a `[env]` sub-table
43
+ // header on its own line, which `removeBlock` (below) mistakes for a
44
+ // sibling top-level table -- corrupting idempotent merges and leaking
45
+ // secrets on uninstall (the header + its keys survive as an orphan
46
+ // block). Render `env` as a single-line inline table instead, which stays
47
+ // inside this block and cannot be mistaken for a new `[table]` header.
48
+ const envLine = `env = { ${envKeys
49
+ .map((k) => `${tomlKey(k)} = ${tomlString(entry.env[k])}`)
50
+ .join(", ")} }`;
51
+ return `${base}\n${envLine}`;
52
+ }
53
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/;
54
+ function tomlKey(key) {
55
+ return BARE_KEY.test(key) ? key : tomlString(key);
56
+ }
57
+ function tomlString(value) {
58
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
59
+ }
60
+ /**
61
+ * Insert, replace, or remove a single TOML table (identified by its dotted
62
+ * `key`, e.g. `mcp_servers.drawio`) in `source`, leaving every other byte of
63
+ * the file untouched.
64
+ *
65
+ * `smol-toml`'s `parse` -> `stringify` round-trip (as sketched in the task
66
+ * brief) discards *all* comments and reformats the entire document -- not
67
+ * just the table being edited. That fails round-trip expectations for any
68
+ * config a human may have hand-edited. Splicing the target table's text
69
+ * directly avoids touching unrelated tables, comments, or blank-line layout.
70
+ */
71
+ function mergeTomlBlock(source, name, body) {
72
+ const header = `[mcp_servers.${name}]`;
73
+ const lines = source.length > 0 ? source.split("\n") : [];
74
+ assertNoInlineInParentForm(lines, name);
75
+ const spliced = removeBlock(lines, name);
76
+ if (body === null)
77
+ return spliced.join("\n");
78
+ return appendBlock(spliced, header, body).join("\n");
79
+ }
80
+ /**
81
+ * Does `line` open the `[mcp_servers.<name>]` table, tolerating TOML-legal
82
+ * variants a hand-authored config might use: a quoted key segment
83
+ * (`[mcp_servers."drawio"]`) and/or whitespace around the `.` separator
84
+ * (`[mcp_servers . drawio]`)?
85
+ */
86
+ function isTargetHeader(line, name) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
89
+ return false;
90
+ const inner = trimmed.slice(1, -1).trim();
91
+ const segments = inner.split(/\s*\.\s*/).map(unquoteTomlKey);
92
+ return (segments.length === 2 &&
93
+ segments[0] === "mcp_servers" &&
94
+ segments[1] === name);
95
+ }
96
+ function unquoteTomlKey(segment) {
97
+ const s = segment.trim();
98
+ if (s.length >= 2) {
99
+ const first = s[0];
100
+ const last = s[s.length - 1];
101
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
102
+ return s.slice(1, -1);
103
+ }
104
+ }
105
+ return s;
106
+ }
107
+ /**
108
+ * The inline-in-parent form (`[mcp_servers]` header followed by a bare
109
+ * `<name> = { ... }` assignment) is a different TOML structure from a
110
+ * nested `[mcp_servers.<name>]` table, and this tool's block-splice merge
111
+ * doesn't understand it. Rather than silently corrupt a hand-authored
112
+ * config (e.g. by appending a second, conflicting `drawio` entry), fail
113
+ * loudly and tell the user how to fix it.
114
+ */
115
+ function assertNoInlineInParentForm(lines, name) {
116
+ const startIdx = lines.findIndex((line) => {
117
+ const trimmed = line.trim();
118
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
119
+ return false;
120
+ return trimmed.slice(1, -1).trim() === "mcp_servers";
121
+ });
122
+ if (startIdx === -1)
123
+ return;
124
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ const assignment = new RegExp(`^\\s*(?:${escapedName}|"${escapedName}"|'${escapedName}')\\s*=`);
126
+ for (let i = startIdx + 1; i < lines.length; i++) {
127
+ const line = lines[i];
128
+ if (/^\s*\[[A-Za-z_"]/.test(line))
129
+ break;
130
+ if (assignment.test(line)) {
131
+ throw new Error(`Found "${name}" defined inline under [mcp_servers] (e.g. \`${name} = { ... }\`), ` +
132
+ `which this tool doesn't support. Please move the entry into a ` +
133
+ `[mcp_servers.${name}] table, or use --config-path to point at a different config file.`);
134
+ }
135
+ }
136
+ }
137
+ function removeBlock(lines, name) {
138
+ const startIdx = lines.findIndex((line) => isTargetHeader(line, name));
139
+ if (startIdx === -1)
140
+ return lines;
141
+ let endIdx = lines.length;
142
+ for (let i = startIdx + 1; i < lines.length; i++) {
143
+ if (/^\s*\[[A-Za-z_"]/.test(lines[i])) {
144
+ endIdx = i;
145
+ break;
146
+ }
147
+ }
148
+ // Drop trailing blank lines that belong to the removed block.
149
+ while (endIdx > startIdx + 1 && lines[endIdx - 1].trim() === "") {
150
+ endIdx--;
151
+ }
152
+ const before = lines.slice(0, startIdx);
153
+ // Drop the single blank separator line preceding the block, if any, so
154
+ // removal doesn't leave a double blank line behind.
155
+ if (before.length > 0 && before[before.length - 1].trim() === "") {
156
+ before.pop();
157
+ }
158
+ const after = lines.slice(endIdx);
159
+ return [...before, ...after];
160
+ }
161
+ function appendBlock(lines, header, body) {
162
+ const bodyLines = body.split("\n");
163
+ // Drop the single trailing empty element representing the file's final
164
+ // newline, so we don't accumulate blank lines across repeated merges.
165
+ const trimmed = lines.length > 0 && lines[lines.length - 1] === ""
166
+ ? lines.slice(0, -1)
167
+ : lines;
168
+ if (trimmed.length === 0)
169
+ return [header, ...bodyLines, ""];
170
+ return [...trimmed, "", header, ...bodyLines, ""];
171
+ }