drawio-mcp-server 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +416 -82
- package/build/install-desktop-plugin.js +56 -0
- package/build/install-desktop-plugin.test.js +66 -0
- package/build/multi-transport.test.js +117 -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 +1 -1
- package/build/real-environment/harness.js +118 -31
- 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 +12 -4
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { create_request_queue } from "./request_queue.js";
|
|
3
|
+
import { create_logger } from "./standard_console_logger.js";
|
|
4
|
+
describe("request queue", () => {
|
|
5
|
+
function activeTailCount(queue) {
|
|
6
|
+
return queue._active_tail_count();
|
|
7
|
+
}
|
|
8
|
+
it("runs enqueued tasks in FIFO order", async () => {
|
|
9
|
+
const queue = create_request_queue(create_logger());
|
|
10
|
+
const steps = [];
|
|
11
|
+
let releaseFirst;
|
|
12
|
+
const first = queue.enqueue("alpha", async () => {
|
|
13
|
+
steps.push("first:start");
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
releaseFirst = resolve;
|
|
16
|
+
});
|
|
17
|
+
steps.push("first:end");
|
|
18
|
+
return "first";
|
|
19
|
+
});
|
|
20
|
+
const second = queue.enqueue("alpha", async () => {
|
|
21
|
+
steps.push("second:start");
|
|
22
|
+
steps.push("second:end");
|
|
23
|
+
return "second";
|
|
24
|
+
});
|
|
25
|
+
await Promise.resolve();
|
|
26
|
+
expect(steps).toEqual(["first:start"]);
|
|
27
|
+
releaseFirst();
|
|
28
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
29
|
+
"first",
|
|
30
|
+
"second",
|
|
31
|
+
]);
|
|
32
|
+
expect(steps).toEqual([
|
|
33
|
+
"first:start",
|
|
34
|
+
"first:end",
|
|
35
|
+
"second:start",
|
|
36
|
+
"second:end",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
it("continues processing after a rejected task", async () => {
|
|
40
|
+
const queue = create_request_queue(create_logger());
|
|
41
|
+
const steps = [];
|
|
42
|
+
const first = queue.enqueue("alpha", async () => {
|
|
43
|
+
steps.push("first");
|
|
44
|
+
throw new Error("boom");
|
|
45
|
+
});
|
|
46
|
+
const second = queue.enqueue("alpha", async () => {
|
|
47
|
+
steps.push("second");
|
|
48
|
+
return "ok";
|
|
49
|
+
});
|
|
50
|
+
await expect(first).rejects.toThrow("boom");
|
|
51
|
+
await expect(second).resolves.toBe("ok");
|
|
52
|
+
expect(steps).toEqual(["first", "second"]);
|
|
53
|
+
});
|
|
54
|
+
it("allows different queue keys to run independently", async () => {
|
|
55
|
+
const queue = create_request_queue(create_logger());
|
|
56
|
+
const steps = [];
|
|
57
|
+
let releaseFirst;
|
|
58
|
+
const first = queue.enqueue("alpha", async () => {
|
|
59
|
+
steps.push("alpha:start");
|
|
60
|
+
await new Promise((resolve) => {
|
|
61
|
+
releaseFirst = resolve;
|
|
62
|
+
});
|
|
63
|
+
steps.push("alpha:end");
|
|
64
|
+
return "alpha";
|
|
65
|
+
});
|
|
66
|
+
const second = queue.enqueue("beta", async () => {
|
|
67
|
+
steps.push("beta:start");
|
|
68
|
+
steps.push("beta:end");
|
|
69
|
+
return "beta";
|
|
70
|
+
});
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
await expect(second).resolves.toBe("beta");
|
|
73
|
+
expect(steps).toEqual(["alpha:start", "beta:start", "beta:end"]);
|
|
74
|
+
releaseFirst();
|
|
75
|
+
await expect(first).resolves.toBe("alpha");
|
|
76
|
+
expect(steps).toEqual([
|
|
77
|
+
"alpha:start",
|
|
78
|
+
"beta:start",
|
|
79
|
+
"beta:end",
|
|
80
|
+
"alpha:end",
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
it("cleans up finished queue keys", async () => {
|
|
84
|
+
const queue = create_request_queue(create_logger());
|
|
85
|
+
expect(activeTailCount(queue)).toBe(0);
|
|
86
|
+
await expect(queue.enqueue("alpha", async () => "alpha")).resolves.toBe("alpha");
|
|
87
|
+
await Promise.resolve();
|
|
88
|
+
expect(activeTailCount(queue)).toBe(0);
|
|
89
|
+
const first = queue.enqueue("alpha", async () => "first");
|
|
90
|
+
const second = queue.enqueue("beta", async () => "second");
|
|
91
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
92
|
+
"first",
|
|
93
|
+
"second",
|
|
94
|
+
]);
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
expect(activeTailCount(queue)).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
async function getFreePort() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const srv = createServer();
|
|
8
|
+
srv.once("error", reject);
|
|
9
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
10
|
+
const addr = srv.address();
|
|
11
|
+
if (addr && typeof addr === "object") {
|
|
12
|
+
const port = addr.port;
|
|
13
|
+
srv.close(() => resolve(port));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
srv.close(() => reject(new Error("could not resolve free port")));
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
// Tests run from build/, so build/index.js is sibling to this compiled file.
|
|
23
|
+
const SERVER_BIN = join(here, "index.js");
|
|
24
|
+
describe("stdio transport stdout purity", () => {
|
|
25
|
+
let proc;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (proc && !proc.killed) {
|
|
28
|
+
proc.kill("SIGTERM");
|
|
29
|
+
}
|
|
30
|
+
proc = undefined;
|
|
31
|
+
});
|
|
32
|
+
it("emits only valid JSON-RPC frames on stdout during initialize", async () => {
|
|
33
|
+
const extensionPort = await getFreePort();
|
|
34
|
+
const httpPort = await getFreePort();
|
|
35
|
+
proc = spawn(process.execPath, [
|
|
36
|
+
SERVER_BIN,
|
|
37
|
+
"--transport",
|
|
38
|
+
"stdio",
|
|
39
|
+
"--extension-port",
|
|
40
|
+
String(extensionPort),
|
|
41
|
+
"--http-port",
|
|
42
|
+
String(httpPort),
|
|
43
|
+
"--host",
|
|
44
|
+
"127.0.0.1",
|
|
45
|
+
], {
|
|
46
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
const stdoutChunks = [];
|
|
49
|
+
const stderrChunks = [];
|
|
50
|
+
proc.stdout.on("data", (b) => stdoutChunks.push(b.toString("utf8")));
|
|
51
|
+
proc.stderr.on("data", (b) => stderrChunks.push(b.toString("utf8")));
|
|
52
|
+
const initRequest = {
|
|
53
|
+
jsonrpc: "2.0",
|
|
54
|
+
id: 1,
|
|
55
|
+
method: "initialize",
|
|
56
|
+
params: {
|
|
57
|
+
protocolVersion: "2025-03-26",
|
|
58
|
+
capabilities: {},
|
|
59
|
+
clientInfo: { name: "purity-test", version: "1.0.0" },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
proc.stdin.write(`${JSON.stringify(initRequest)}\n`);
|
|
63
|
+
// Wait for the initialize response to arrive on stdout.
|
|
64
|
+
const response = await new Promise((resolve, reject) => {
|
|
65
|
+
const timer = setTimeout(() => reject(new Error("timed out waiting for initialize response")), 10000);
|
|
66
|
+
const onChunk = () => {
|
|
67
|
+
const text = stdoutChunks.join("");
|
|
68
|
+
if (text.includes("\n")) {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
proc?.stdout.off("data", onChunk);
|
|
71
|
+
resolve(text);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
proc?.stdout.on("data", onChunk);
|
|
75
|
+
// Re-check synchronously in case data arrived before we attached.
|
|
76
|
+
onChunk();
|
|
77
|
+
});
|
|
78
|
+
const lines = response.split("\n").filter((line) => line.length > 0);
|
|
79
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
82
|
+
}
|
|
83
|
+
const parsed = JSON.parse(lines[0]);
|
|
84
|
+
expect(parsed.jsonrpc).toBe("2.0");
|
|
85
|
+
expect(parsed.id).toBe(1);
|
|
86
|
+
expect(parsed.result?.serverInfo?.name).toBe("drawio-mcp-server");
|
|
87
|
+
}, 20000);
|
|
88
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips the `$schema` key from a JSON Schema object.
|
|
3
|
+
* The `$schema` key injected by zodToJsonSchema causes Claude Code to silently
|
|
4
|
+
* drop tools because the `$` character fails Anthropic's validation regex
|
|
5
|
+
* `^[a-zA-Z0-9_.-]{1,64}$`.
|
|
6
|
+
*
|
|
7
|
+
* @param schema - The input schema object (may have `$schema` key)
|
|
8
|
+
* @returns The schema without `$schema` key
|
|
9
|
+
*/
|
|
10
|
+
export function stripSchemaKey(schema) {
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
12
|
+
const { $schema, ...rest } = schema;
|
|
13
|
+
return rest;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Recursively strips the `$schema` key from a JSON Schema object and all nested schemas.
|
|
17
|
+
* This handles cases like `definitions` in the schema.
|
|
18
|
+
*
|
|
19
|
+
* @param schema - The input schema object
|
|
20
|
+
* @returns The schema without `$schema` key
|
|
21
|
+
*/
|
|
22
|
+
export function stripSchemaRecursively(schema) {
|
|
23
|
+
if (!schema || typeof schema !== "object") {
|
|
24
|
+
return schema;
|
|
25
|
+
}
|
|
26
|
+
// Create a shallow copy first
|
|
27
|
+
const result = { ...schema };
|
|
28
|
+
// Remove $schema if present
|
|
29
|
+
if ("$schema" in result) {
|
|
30
|
+
delete result["$schema"];
|
|
31
|
+
}
|
|
32
|
+
// Recursively process properties
|
|
33
|
+
if ("properties" in result && typeof result.properties === "object") {
|
|
34
|
+
result.properties = stripSchemaRecursively(result.properties);
|
|
35
|
+
}
|
|
36
|
+
// Recursively process items
|
|
37
|
+
if ("items" in result && typeof result.items === "object") {
|
|
38
|
+
result.items = stripSchemaRecursively(result.items);
|
|
39
|
+
}
|
|
40
|
+
// Recursively process additionalProperties
|
|
41
|
+
if ("additionalProperties" in result &&
|
|
42
|
+
typeof result.additionalProperties === "object") {
|
|
43
|
+
result.additionalProperties = stripSchemaRecursively(result.additionalProperties);
|
|
44
|
+
}
|
|
45
|
+
// Recursively process patternProperties
|
|
46
|
+
if ("patternProperties" in result &&
|
|
47
|
+
typeof result.patternProperties === "object") {
|
|
48
|
+
result.patternProperties = stripSchemaRecursively(result.patternProperties);
|
|
49
|
+
}
|
|
50
|
+
// Process definitions
|
|
51
|
+
if ("definitions" in result && typeof result.definitions === "object") {
|
|
52
|
+
result.definitions = stripSchemaRecursively(result.definitions);
|
|
53
|
+
}
|
|
54
|
+
// Process anyOf, allOf, oneOf
|
|
55
|
+
for (const key of ["anyOf", "allOf", "oneOf"]) {
|
|
56
|
+
if (key in result && Array.isArray(result[key])) {
|
|
57
|
+
result[key] = result[key].map((item) => typeof item === "object" ? stripSchemaRecursively(item) : item);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const RENEWAL_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
2
|
+
export function evaluateMaterial(args) {
|
|
3
|
+
if (!args.meta)
|
|
4
|
+
return "missing";
|
|
5
|
+
const caExpiresAt = new Date(args.meta.caNotAfter).getTime();
|
|
6
|
+
if (caExpiresAt - args.now.getTime() < RENEWAL_WINDOW_MS)
|
|
7
|
+
return "ca-expired";
|
|
8
|
+
const leafExpiresAt = new Date(args.meta.serverNotAfter).getTime();
|
|
9
|
+
if (leafExpiresAt - args.now.getTime() < RENEWAL_WINDOW_MS)
|
|
10
|
+
return "leaf-expired";
|
|
11
|
+
if (args.meta.sanHash !== args.currentSanHash)
|
|
12
|
+
return "san-drift";
|
|
13
|
+
return "valid";
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { evaluateMaterial } from "./expiry.js";
|
|
3
|
+
const baseMeta = (overrides = {}) => ({
|
|
4
|
+
version: 1,
|
|
5
|
+
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
6
|
+
sanHash: "hash-A",
|
|
7
|
+
caNotAfter: "2036-01-01T00:00:00.000Z",
|
|
8
|
+
serverNotAfter: "2027-01-01T00:00:00.000Z",
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
describe("evaluateMaterial", () => {
|
|
12
|
+
it("returns 'missing' when meta is null", () => {
|
|
13
|
+
expect(evaluateMaterial({
|
|
14
|
+
meta: null,
|
|
15
|
+
currentSanHash: "hash-A",
|
|
16
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
17
|
+
})).toBe("missing");
|
|
18
|
+
});
|
|
19
|
+
it("returns 'valid' when CA + leaf future-valid and SAN matches", () => {
|
|
20
|
+
expect(evaluateMaterial({
|
|
21
|
+
meta: baseMeta(),
|
|
22
|
+
currentSanHash: "hash-A",
|
|
23
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
24
|
+
})).toBe("valid");
|
|
25
|
+
});
|
|
26
|
+
it("returns 'san-drift' when SAN hash differs but CA still valid", () => {
|
|
27
|
+
expect(evaluateMaterial({
|
|
28
|
+
meta: baseMeta(),
|
|
29
|
+
currentSanHash: "hash-B",
|
|
30
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
31
|
+
})).toBe("san-drift");
|
|
32
|
+
});
|
|
33
|
+
it("returns 'leaf-expired' when leaf within 30-day expiry window", () => {
|
|
34
|
+
expect(evaluateMaterial({
|
|
35
|
+
meta: baseMeta({ serverNotAfter: "2026-06-15T00:00:00.000Z" }),
|
|
36
|
+
currentSanHash: "hash-A",
|
|
37
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
38
|
+
})).toBe("leaf-expired");
|
|
39
|
+
});
|
|
40
|
+
it("returns 'ca-expired' when CA within 30-day expiry window (overrides leaf)", () => {
|
|
41
|
+
expect(evaluateMaterial({
|
|
42
|
+
meta: baseMeta({ caNotAfter: "2026-06-15T00:00:00.000Z" }),
|
|
43
|
+
currentSanHash: "hash-A",
|
|
44
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
45
|
+
})).toBe("ca-expired");
|
|
46
|
+
});
|
|
47
|
+
it("CA expired wins over SAN drift", () => {
|
|
48
|
+
expect(evaluateMaterial({
|
|
49
|
+
meta: baseMeta({ caNotAfter: "2026-06-15T00:00:00.000Z" }),
|
|
50
|
+
currentSanHash: "hash-B",
|
|
51
|
+
now: new Date("2026-06-01T00:00:00Z"),
|
|
52
|
+
})).toBe("ca-expired");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import forge from "node-forge";
|
|
4
|
+
const SERIAL_BYTES = 16;
|
|
5
|
+
function randomSerialHex() {
|
|
6
|
+
const bytes = forge.random.getBytesSync(SERIAL_BYTES);
|
|
7
|
+
const arr = Array.from(bytes, (c) => c.charCodeAt(0));
|
|
8
|
+
arr[0] = arr[0] & 0x7f;
|
|
9
|
+
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
function setValidity(cert, from, years) {
|
|
12
|
+
cert.validity.notBefore = from;
|
|
13
|
+
const to = new Date(from);
|
|
14
|
+
to.setUTCFullYear(to.getUTCFullYear() + years);
|
|
15
|
+
cert.validity.notAfter = to;
|
|
16
|
+
}
|
|
17
|
+
export function generateCa(args) {
|
|
18
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
19
|
+
const cert = forge.pki.createCertificate();
|
|
20
|
+
cert.publicKey = keys.publicKey;
|
|
21
|
+
cert.serialNumber = randomSerialHex();
|
|
22
|
+
setValidity(cert, args.now, 10);
|
|
23
|
+
const attrs = [{ name: "commonName", value: "drawio-mcp-server local CA" }];
|
|
24
|
+
cert.setSubject(attrs);
|
|
25
|
+
cert.setIssuer(attrs);
|
|
26
|
+
cert.setExtensions([
|
|
27
|
+
{ name: "basicConstraints", cA: true, critical: true },
|
|
28
|
+
{
|
|
29
|
+
name: "keyUsage",
|
|
30
|
+
keyCertSign: true,
|
|
31
|
+
cRLSign: true,
|
|
32
|
+
critical: true,
|
|
33
|
+
},
|
|
34
|
+
{ name: "subjectKeyIdentifier" },
|
|
35
|
+
]);
|
|
36
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
37
|
+
return {
|
|
38
|
+
certPem: forge.pki.certificateToPem(cert),
|
|
39
|
+
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
|
40
|
+
cert,
|
|
41
|
+
keys,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function sanListToAltNames(sanList) {
|
|
45
|
+
return sanList.map((entry) => entry.type === "dns"
|
|
46
|
+
? { type: 2, value: entry.value }
|
|
47
|
+
: { type: 7, ip: entry.value });
|
|
48
|
+
}
|
|
49
|
+
export function generateLeaf(args) {
|
|
50
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
51
|
+
const cert = forge.pki.createCertificate();
|
|
52
|
+
cert.publicKey = keys.publicKey;
|
|
53
|
+
cert.serialNumber = randomSerialHex();
|
|
54
|
+
setValidity(cert, args.now, 1);
|
|
55
|
+
cert.setSubject([{ name: "commonName", value: "drawio-mcp-server" }]);
|
|
56
|
+
cert.setIssuer(args.ca.cert.subject.attributes);
|
|
57
|
+
cert.setExtensions([
|
|
58
|
+
{ name: "basicConstraints", cA: false, critical: true },
|
|
59
|
+
{
|
|
60
|
+
name: "keyUsage",
|
|
61
|
+
digitalSignature: true,
|
|
62
|
+
keyEncipherment: true,
|
|
63
|
+
critical: true,
|
|
64
|
+
},
|
|
65
|
+
{ name: "extKeyUsage", serverAuth: true },
|
|
66
|
+
{ name: "subjectAltName", altNames: sanListToAltNames(args.sanList) },
|
|
67
|
+
{ name: "subjectKeyIdentifier" },
|
|
68
|
+
]);
|
|
69
|
+
cert.sign(args.ca.keys.privateKey, forge.md.sha256.create());
|
|
70
|
+
return {
|
|
71
|
+
certPem: forge.pki.certificateToPem(cert),
|
|
72
|
+
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
|
73
|
+
cert,
|
|
74
|
+
keys,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function writeMaterial(args) {
|
|
78
|
+
mkdirSync(dirname(args.paths.caCert), { recursive: true, mode: 0o700 });
|
|
79
|
+
writeFileSync(args.paths.caCert, args.ca.certPem, { mode: 0o644 });
|
|
80
|
+
writeFileSync(args.paths.caKey, args.ca.keyPem, { mode: 0o600 });
|
|
81
|
+
writeFileSync(args.paths.serverCert, args.leaf.certPem, { mode: 0o644 });
|
|
82
|
+
writeFileSync(args.paths.serverKey, args.leaf.keyPem, { mode: 0o600 });
|
|
83
|
+
const meta = {
|
|
84
|
+
version: 1,
|
|
85
|
+
generatedAt: args.generatedAt.toISOString(),
|
|
86
|
+
sanHash: args.sanHash,
|
|
87
|
+
caNotAfter: args.ca.cert.validity.notAfter.toISOString(),
|
|
88
|
+
serverNotAfter: args.leaf.cert.validity.notAfter.toISOString(),
|
|
89
|
+
};
|
|
90
|
+
writeFileSync(args.paths.meta, JSON.stringify(meta, null, 2), {
|
|
91
|
+
mode: 0o644,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function readMeta(paths) {
|
|
95
|
+
if (!existsSync(paths.meta))
|
|
96
|
+
return null;
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
const raw = readFileSync(paths.meta, "utf8");
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (parsed?.version !== 1)
|
|
106
|
+
return null;
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
export function loadCaMaterial(paths) {
|
|
110
|
+
if (!existsSync(paths.caCert) || !existsSync(paths.caKey)) {
|
|
111
|
+
throw new Error(`TLS material directory is in an inconsistent state: ${dirname(paths.caCert)}. Delete it and restart.`);
|
|
112
|
+
}
|
|
113
|
+
const certPem = readFileSync(paths.caCert, "utf8");
|
|
114
|
+
const keyPem = readFileSync(paths.caKey, "utf8");
|
|
115
|
+
const cert = forge.pki.certificateFromPem(certPem);
|
|
116
|
+
const privateKey = forge.pki.privateKeyFromPem(keyPem);
|
|
117
|
+
return {
|
|
118
|
+
certPem,
|
|
119
|
+
keyPem,
|
|
120
|
+
cert,
|
|
121
|
+
keys: { privateKey, publicKey: cert.publicKey },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import forge from "node-forge";
|
|
6
|
+
import { generateCa, generateLeaf, writeMaterial, readMeta, } from "./generate.js";
|
|
7
|
+
import { tlsFilePaths } from "./paths.js";
|
|
8
|
+
import { buildSanList } from "./san.js";
|
|
9
|
+
describe("generateCa", () => {
|
|
10
|
+
it("produces a self-signed CA cert with basicConstraints CA:true", () => {
|
|
11
|
+
const ca = generateCa({ now: new Date("2026-01-01T00:00:00Z") });
|
|
12
|
+
const cert = forge.pki.certificateFromPem(ca.certPem);
|
|
13
|
+
const bc = cert.getExtension("basicConstraints");
|
|
14
|
+
expect(bc?.cA).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it("validity is 10 years from `now`", () => {
|
|
17
|
+
const now = new Date("2026-01-01T00:00:00Z");
|
|
18
|
+
const ca = generateCa({ now });
|
|
19
|
+
const cert = forge.pki.certificateFromPem(ca.certPem);
|
|
20
|
+
expect(cert.validity.notBefore.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
|
21
|
+
expect(cert.validity.notAfter.toISOString()).toBe("2036-01-01T00:00:00.000Z");
|
|
22
|
+
});
|
|
23
|
+
it("subject CN identifies the app", () => {
|
|
24
|
+
const ca = generateCa({ now: new Date() });
|
|
25
|
+
const cert = forge.pki.certificateFromPem(ca.certPem);
|
|
26
|
+
const cn = cert.subject.getField("CN")?.value;
|
|
27
|
+
expect(cn).toBe("drawio-mcp-server local CA");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("generateLeaf", () => {
|
|
31
|
+
const ca = generateCa({ now: new Date("2026-01-01T00:00:00Z") });
|
|
32
|
+
const sanList = buildSanList("192.168.1.10");
|
|
33
|
+
it("is signed by the CA (issuer = CA subject)", () => {
|
|
34
|
+
const leaf = generateLeaf({
|
|
35
|
+
ca,
|
|
36
|
+
sanList,
|
|
37
|
+
now: new Date("2026-01-01T00:00:00Z"),
|
|
38
|
+
});
|
|
39
|
+
const cert = forge.pki.certificateFromPem(leaf.certPem);
|
|
40
|
+
const caCert = forge.pki.certificateFromPem(ca.certPem);
|
|
41
|
+
expect(cert.issuer.hash).toBe(caCert.subject.hash);
|
|
42
|
+
});
|
|
43
|
+
it("validity is 1 year from `now`", () => {
|
|
44
|
+
const now = new Date("2026-01-01T00:00:00Z");
|
|
45
|
+
const leaf = generateLeaf({ ca, sanList, now });
|
|
46
|
+
const cert = forge.pki.certificateFromPem(leaf.certPem);
|
|
47
|
+
expect(cert.validity.notAfter.toISOString()).toBe("2027-01-01T00:00:00.000Z");
|
|
48
|
+
});
|
|
49
|
+
it("includes every SAN entry with correct type", () => {
|
|
50
|
+
const leaf = generateLeaf({ ca, sanList, now: new Date() });
|
|
51
|
+
const cert = forge.pki.certificateFromPem(leaf.certPem);
|
|
52
|
+
const ext = cert.getExtension("subjectAltName");
|
|
53
|
+
const altNames = ext?.altNames ?? [];
|
|
54
|
+
expect(altNames.find((a) => a.type === 2 && a.value === "localhost")).toBeTruthy();
|
|
55
|
+
expect(altNames.find((a) => a.type === 7 && a.ip === "127.0.0.1")).toBeTruthy();
|
|
56
|
+
expect(altNames.find((a) => a.type === 7 && a.ip === "::1")).toBeTruthy();
|
|
57
|
+
expect(altNames.find((a) => a.type === 7 && a.ip === "192.168.1.10")).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
it("EKU includes serverAuth", () => {
|
|
60
|
+
const leaf = generateLeaf({ ca, sanList, now: new Date() });
|
|
61
|
+
const cert = forge.pki.certificateFromPem(leaf.certPem);
|
|
62
|
+
const eku = cert.getExtension("extKeyUsage");
|
|
63
|
+
expect(eku?.serverAuth).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("writeMaterial / readMeta", () => {
|
|
67
|
+
it("writes all PEM files and meta.json with private keys at mode 0600 (POSIX)", () => {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "tls-test-"));
|
|
69
|
+
const paths = tlsFilePaths(dir);
|
|
70
|
+
const ca = generateCa({ now: new Date("2026-01-01T00:00:00Z") });
|
|
71
|
+
const leaf = generateLeaf({
|
|
72
|
+
ca,
|
|
73
|
+
sanList: buildSanList(undefined),
|
|
74
|
+
now: new Date("2026-01-01T00:00:00Z"),
|
|
75
|
+
});
|
|
76
|
+
writeMaterial({
|
|
77
|
+
paths,
|
|
78
|
+
ca,
|
|
79
|
+
leaf,
|
|
80
|
+
sanHash: "abc",
|
|
81
|
+
generatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
82
|
+
});
|
|
83
|
+
expect(readFileSync(paths.caCert, "utf8")).toContain("BEGIN CERTIFICATE");
|
|
84
|
+
expect(readFileSync(paths.serverCert, "utf8")).toContain("BEGIN CERTIFICATE");
|
|
85
|
+
expect(readFileSync(paths.caKey, "utf8")).toContain("PRIVATE KEY");
|
|
86
|
+
expect(readFileSync(paths.serverKey, "utf8")).toContain("PRIVATE KEY");
|
|
87
|
+
if (process.platform !== "win32") {
|
|
88
|
+
expect(statSync(paths.caKey).mode & 0o777).toBe(0o600);
|
|
89
|
+
expect(statSync(paths.serverKey).mode & 0o777).toBe(0o600);
|
|
90
|
+
expect(statSync(paths.caCert).mode & 0o777).toBe(0o644);
|
|
91
|
+
expect(statSync(paths.serverCert).mode & 0o777).toBe(0o644);
|
|
92
|
+
expect(statSync(paths.meta).mode & 0o777).toBe(0o644);
|
|
93
|
+
}
|
|
94
|
+
const meta = readMeta(paths);
|
|
95
|
+
expect(meta?.sanHash).toBe("abc");
|
|
96
|
+
expect(meta?.caNotAfter).toBe("2036-01-01T00:00:00.000Z");
|
|
97
|
+
expect(meta?.serverNotAfter).toBe("2027-01-01T00:00:00.000Z");
|
|
98
|
+
});
|
|
99
|
+
it("readMeta returns null when meta.json missing", () => {
|
|
100
|
+
const dir = mkdtempSync(join(tmpdir(), "tls-test-"));
|
|
101
|
+
expect(readMeta(tlsFilePaths(dir))).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it("readMeta returns null when meta.json has unknown version", () => {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), "tls-test-"));
|
|
105
|
+
const paths = tlsFilePaths(dir);
|
|
106
|
+
writeFileSync(paths.meta, JSON.stringify({ version: 99, sanHash: "x" }));
|
|
107
|
+
expect(readMeta(paths)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
it("readMeta returns null when meta.json is malformed", () => {
|
|
110
|
+
const dir = mkdtempSync(join(tmpdir(), "tls-test-"));
|
|
111
|
+
const paths = tlsFilePaths(dir);
|
|
112
|
+
writeFileSync(paths.meta, "{not json");
|
|
113
|
+
expect(readMeta(paths)).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { homedir, platform as osPlatform } from "node:os";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { generateCa, generateLeaf, readMeta, writeMaterial, loadCaMaterial, } from "./generate.js";
|
|
4
|
+
import { caInstallHint } from "./install-hint.js";
|
|
5
|
+
import { evaluateMaterial } from "./expiry.js";
|
|
6
|
+
import { loadManualMaterial } from "./load.js";
|
|
7
|
+
import { resolveTlsDir, tlsFilePaths } from "./paths.js";
|
|
8
|
+
import { buildSanList, sanHash } from "./san.js";
|
|
9
|
+
export function resolveTlsMaterial(args) {
|
|
10
|
+
const { config } = args;
|
|
11
|
+
if (!config.tlsEnabled)
|
|
12
|
+
return null;
|
|
13
|
+
const hasManual = Boolean(config.tlsCert || config.tlsKey);
|
|
14
|
+
const hasAuto = Boolean(config.tlsAuto);
|
|
15
|
+
if (hasManual && hasAuto) {
|
|
16
|
+
throw new Error("Cannot combine --tls-auto with --tls-cert/--tls-key. Pick one mode.");
|
|
17
|
+
}
|
|
18
|
+
if (!hasManual && !hasAuto) {
|
|
19
|
+
throw new Error("--tls requires either --tls-auto or --tls-cert/--tls-key");
|
|
20
|
+
}
|
|
21
|
+
if (hasManual) {
|
|
22
|
+
if (!config.tlsCert || !config.tlsKey) {
|
|
23
|
+
throw new Error("--tls-cert and --tls-key must both be provided");
|
|
24
|
+
}
|
|
25
|
+
const m = loadManualMaterial({
|
|
26
|
+
certPath: config.tlsCert,
|
|
27
|
+
keyPath: config.tlsKey,
|
|
28
|
+
});
|
|
29
|
+
return { cert: m.cert, key: m.key, caPath: undefined };
|
|
30
|
+
}
|
|
31
|
+
// Auto mode
|
|
32
|
+
const now = args.now ?? new Date();
|
|
33
|
+
const platform = args.platform ?? osPlatform();
|
|
34
|
+
const env = args.env ?? process.env;
|
|
35
|
+
const home = args.home ?? homedir();
|
|
36
|
+
const dir = resolveTlsDir({
|
|
37
|
+
override: config.tlsDir,
|
|
38
|
+
platform,
|
|
39
|
+
env,
|
|
40
|
+
home,
|
|
41
|
+
});
|
|
42
|
+
const paths = tlsFilePaths(dir);
|
|
43
|
+
const sanList = buildSanList(config.host);
|
|
44
|
+
const currentSanHash = sanHash(sanList);
|
|
45
|
+
const meta = readMeta(paths);
|
|
46
|
+
const state = evaluateMaterial({ meta, currentSanHash, now });
|
|
47
|
+
if (state === "valid") {
|
|
48
|
+
if (existsSync(paths.serverCert) && existsSync(paths.serverKey)) {
|
|
49
|
+
return {
|
|
50
|
+
cert: readFileSync(paths.serverCert, "utf8"),
|
|
51
|
+
key: readFileSync(paths.serverKey, "utf8"),
|
|
52
|
+
caPath: paths.caCert,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// meta said valid but files vanished — treat as missing
|
|
56
|
+
}
|
|
57
|
+
let ca;
|
|
58
|
+
const cacheLost = state === "valid"; // we already returned if cache existed
|
|
59
|
+
if (!cacheLost && (state === "san-drift" || state === "leaf-expired")) {
|
|
60
|
+
// CA still valid — keep it, regen leaf only
|
|
61
|
+
ca = loadCaMaterial(paths);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// missing, ca-expired, or vanished cache — full regen
|
|
65
|
+
ca = generateCa({ now });
|
|
66
|
+
}
|
|
67
|
+
const leaf = generateLeaf({ ca, sanList, now });
|
|
68
|
+
writeMaterial({ paths, ca, leaf, sanHash: currentSanHash, generatedAt: now });
|
|
69
|
+
if (state !== "san-drift" && state !== "leaf-expired") {
|
|
70
|
+
args.log(`\n${caInstallHint({ platform, caPath: paths.caCert })}\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
args.log(`Renewed TLS leaf certificate (state: ${state}). CA at ${paths.caCert} unchanged.`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
cert: leaf.certPem,
|
|
77
|
+
key: leaf.keyPem,
|
|
78
|
+
caPath: paths.caCert,
|
|
79
|
+
};
|
|
80
|
+
}
|