drawio-mcp-server 2.1.0 → 2.2.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 +1 -1
- package/build/assets/auto-refresh.js +21 -0
- package/build/assets/auto-refresh.test.js +54 -0
- package/build/assets/downloader.js +4 -0
- package/build/assets/version.js +38 -0
- package/build/assets/version.test.js +26 -0
- package/build/documents-changed-broadcast.test.js +123 -0
- package/build/drawio-compat/log-report.js +21 -0
- package/build/drawio-compat/log-report.test.js +43 -0
- package/build/drawio-compat/matrix.js +14 -0
- package/build/index.js +112 -10
- package/build/plugin/mcp-plugin.js +322 -89
- package/build/register-tool.js +72 -20
- package/build/stdio-shutdown.test.js +149 -0
- package/build/vendored/compat/index.js +35 -0
- package/package.json +9 -8
|
@@ -224,94 +224,6 @@ var DrawMcp = (() => {
|
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
function import_mermaid(ui2, options) {
|
|
228
|
-
const opts = options;
|
|
229
|
-
const mermaidSource = opts.mermaid_source;
|
|
230
|
-
const mode = opts.mode ?? "native";
|
|
231
|
-
const insertMode = opts.insert_mode ?? "add";
|
|
232
|
-
if (!mermaidSource || typeof mermaidSource !== "string") {
|
|
233
|
-
return Promise.resolve({
|
|
234
|
-
success: false,
|
|
235
|
-
message: "mermaid_source must be a non-empty string"
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
if (typeof ui2?.parseMermaidDiagram !== "function") {
|
|
239
|
-
return Promise.resolve({
|
|
240
|
-
success: false,
|
|
241
|
-
message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
const enableParser = mode === "native";
|
|
245
|
-
return new Promise((resolve) => {
|
|
246
|
-
let settled = false;
|
|
247
|
-
const settle = (result) => {
|
|
248
|
-
if (settled) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
settled = true;
|
|
252
|
-
resolve(result);
|
|
253
|
-
};
|
|
254
|
-
try {
|
|
255
|
-
ui2.parseMermaidDiagram(
|
|
256
|
-
mermaidSource,
|
|
257
|
-
void 0,
|
|
258
|
-
(xml) => {
|
|
259
|
-
if (!xml || typeof xml !== "string") {
|
|
260
|
-
settle({
|
|
261
|
-
success: false,
|
|
262
|
-
message: "Mermaid parser returned empty XML"
|
|
263
|
-
});
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const importResult = import_diagram(ui2, {
|
|
268
|
-
data: xml,
|
|
269
|
-
format: "xml",
|
|
270
|
-
mode: insertMode
|
|
271
|
-
});
|
|
272
|
-
if (!importResult.success) {
|
|
273
|
-
settle({
|
|
274
|
-
success: false,
|
|
275
|
-
message: `Mermaid converted, but inserting into the diagram failed: ${importResult.message}`
|
|
276
|
-
});
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
settle({
|
|
280
|
-
success: true,
|
|
281
|
-
mode,
|
|
282
|
-
message: importResult.message,
|
|
283
|
-
cells: importResult.cells,
|
|
284
|
-
xml
|
|
285
|
-
});
|
|
286
|
-
} catch (err) {
|
|
287
|
-
settle({
|
|
288
|
-
success: false,
|
|
289
|
-
message: `Insert after Mermaid conversion failed: ${err instanceof Error ? err.message : String(err)}`
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
},
|
|
293
|
-
(err) => {
|
|
294
|
-
settle({
|
|
295
|
-
success: false,
|
|
296
|
-
message: `Mermaid render failed: ${err?.message ?? String(err)}`
|
|
297
|
-
});
|
|
298
|
-
},
|
|
299
|
-
(err) => {
|
|
300
|
-
settle({
|
|
301
|
-
success: false,
|
|
302
|
-
message: `Mermaid parse error: ${err?.message ?? String(err)}`
|
|
303
|
-
});
|
|
304
|
-
},
|
|
305
|
-
enableParser
|
|
306
|
-
);
|
|
307
|
-
} catch (err) {
|
|
308
|
-
settle({
|
|
309
|
-
success: false,
|
|
310
|
-
message: `parseMermaidDiagram threw: ${err instanceof Error ? err.message : String(err)}`
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
227
|
function transform_NamedNodeMap_to_record(attributes) {
|
|
316
228
|
const tstr = Object.prototype.toString.call(attributes);
|
|
317
229
|
if (tstr !== "[object NamedNodeMap]") {
|
|
@@ -2304,6 +2216,271 @@ var DrawMcp = (() => {
|
|
|
2304
2216
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "default";
|
|
2305
2217
|
}
|
|
2306
2218
|
|
|
2219
|
+
// ../drawio-mcp-compat/dist/index.js
|
|
2220
|
+
var SEMVER_HEAD = /^(\d+)\.(\d+)\.(\d+)/;
|
|
2221
|
+
function parseVersion(raw) {
|
|
2222
|
+
const m = SEMVER_HEAD.exec(raw);
|
|
2223
|
+
if (!m)
|
|
2224
|
+
return null;
|
|
2225
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
2226
|
+
}
|
|
2227
|
+
function compareVersion(a, b) {
|
|
2228
|
+
for (let i = 0; i < 3; i++) {
|
|
2229
|
+
if (a[i] < b[i])
|
|
2230
|
+
return -1;
|
|
2231
|
+
if (a[i] > b[i])
|
|
2232
|
+
return 1;
|
|
2233
|
+
}
|
|
2234
|
+
return 0;
|
|
2235
|
+
}
|
|
2236
|
+
function isBelowFloor(v, floor) {
|
|
2237
|
+
const parsed = parseVersion(floor);
|
|
2238
|
+
if (!parsed)
|
|
2239
|
+
return false;
|
|
2240
|
+
return compareVersion(v, parsed) < 0;
|
|
2241
|
+
}
|
|
2242
|
+
function isInRange(v, r) {
|
|
2243
|
+
const min = parseVersion(r.min);
|
|
2244
|
+
if (!min)
|
|
2245
|
+
return false;
|
|
2246
|
+
if (compareVersion(v, min) < 0)
|
|
2247
|
+
return false;
|
|
2248
|
+
if (r.maxExclusive === null)
|
|
2249
|
+
return true;
|
|
2250
|
+
const max = parseVersion(r.maxExclusive);
|
|
2251
|
+
if (!max)
|
|
2252
|
+
return true;
|
|
2253
|
+
return compareVersion(v, max) < 0;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// src/drawio-compat/detect.ts
|
|
2257
|
+
function detectDrawioVersion(ui2) {
|
|
2258
|
+
const raw = globalThis.EditorUi?.VERSION ?? ui2?.constructor?.VERSION;
|
|
2259
|
+
if (typeof raw !== "string") {
|
|
2260
|
+
return { ok: false, reason: "missing", raw: null };
|
|
2261
|
+
}
|
|
2262
|
+
const semver = parseVersion(raw);
|
|
2263
|
+
if (!semver) return { ok: false, reason: "unparseable", raw };
|
|
2264
|
+
return { ok: true, raw, semver };
|
|
2265
|
+
}
|
|
2266
|
+
var cached = null;
|
|
2267
|
+
function getDetectedDrawioVersion(ui2) {
|
|
2268
|
+
if (cached !== null) return cached;
|
|
2269
|
+
const result = detectDrawioVersion(ui2);
|
|
2270
|
+
if (result.ok) cached = result;
|
|
2271
|
+
return result;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// src/drawio-compat/dispatch.ts
|
|
2275
|
+
function dispatchTool(toolName, detected, matrix) {
|
|
2276
|
+
const entries = matrix.versionedTools[toolName];
|
|
2277
|
+
if (!entries || entries.length === 0) return null;
|
|
2278
|
+
if (!detected.ok) {
|
|
2279
|
+
return { kind: "no-version", reason: detected.reason };
|
|
2280
|
+
}
|
|
2281
|
+
if (isBelowFloor(detected.semver, matrix.supportedFloor)) {
|
|
2282
|
+
return {
|
|
2283
|
+
kind: "below-floor",
|
|
2284
|
+
floor: matrix.supportedFloor,
|
|
2285
|
+
detected: detected.raw
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
for (const entry of entries) {
|
|
2289
|
+
if (isInRange(detected.semver, entry.range)) {
|
|
2290
|
+
return { kind: "matched", impl: entry.impl };
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
const sorted = [...entries].sort((a, b) => {
|
|
2294
|
+
const am = parseVersion(a.range.min);
|
|
2295
|
+
const bm = parseVersion(b.range.min);
|
|
2296
|
+
return compareVersion(am, bm);
|
|
2297
|
+
});
|
|
2298
|
+
return {
|
|
2299
|
+
kind: "above-window",
|
|
2300
|
+
lastRangeMin: sorted.at(-1).range.min,
|
|
2301
|
+
detected: detected.raw
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// src/tools/import-mermaid/shared.ts
|
|
2306
|
+
function validateOptions(options) {
|
|
2307
|
+
const opts = options;
|
|
2308
|
+
const source = opts.mermaid_source;
|
|
2309
|
+
if (!source || typeof source !== "string") {
|
|
2310
|
+
return {
|
|
2311
|
+
success: false,
|
|
2312
|
+
message: "mermaid_source must be a non-empty string"
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
return {
|
|
2316
|
+
source,
|
|
2317
|
+
mode: opts.mode ?? "native",
|
|
2318
|
+
insertMode: opts.insert_mode ?? "add"
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
function runInsertFlow(ui2, mode, insertMode, resolve) {
|
|
2322
|
+
let settled = false;
|
|
2323
|
+
const settle = (result) => {
|
|
2324
|
+
if (settled) return;
|
|
2325
|
+
settled = true;
|
|
2326
|
+
resolve(result);
|
|
2327
|
+
};
|
|
2328
|
+
const onXml = (xml) => {
|
|
2329
|
+
if (!xml || typeof xml !== "string") {
|
|
2330
|
+
settle({ success: false, message: "Mermaid parser returned empty XML" });
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
try {
|
|
2334
|
+
const importResult = import_diagram(ui2, {
|
|
2335
|
+
data: xml,
|
|
2336
|
+
format: "xml",
|
|
2337
|
+
mode: insertMode
|
|
2338
|
+
});
|
|
2339
|
+
if (!importResult.success) {
|
|
2340
|
+
settle({
|
|
2341
|
+
success: false,
|
|
2342
|
+
message: `Mermaid converted, but inserting into the diagram failed: ${importResult.message}`
|
|
2343
|
+
});
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
settle({
|
|
2347
|
+
success: true,
|
|
2348
|
+
mode,
|
|
2349
|
+
message: importResult.message,
|
|
2350
|
+
cells: importResult.cells,
|
|
2351
|
+
xml
|
|
2352
|
+
});
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
settle({
|
|
2355
|
+
success: false,
|
|
2356
|
+
message: `Insert after Mermaid conversion failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
const onError = (err) => {
|
|
2361
|
+
settle({
|
|
2362
|
+
success: false,
|
|
2363
|
+
message: `Mermaid render failed: ${err?.message ?? String(err)}`
|
|
2364
|
+
});
|
|
2365
|
+
};
|
|
2366
|
+
return { onXml, onError };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// src/tools/import-mermaid/v29.ts
|
|
2370
|
+
function import_mermaid(ui2, options) {
|
|
2371
|
+
const validated = validateOptions(options);
|
|
2372
|
+
if ("success" in validated) return Promise.resolve(validated);
|
|
2373
|
+
const { source, mode, insertMode } = validated;
|
|
2374
|
+
if (typeof ui2?.parseMermaidDiagram !== "function") {
|
|
2375
|
+
return Promise.resolve({
|
|
2376
|
+
success: false,
|
|
2377
|
+
message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
const enableParser = mode === "native";
|
|
2381
|
+
return new Promise((resolve) => {
|
|
2382
|
+
const { onXml, onError } = runInsertFlow(ui2, mode, insertMode, resolve);
|
|
2383
|
+
try {
|
|
2384
|
+
ui2.parseMermaidDiagram(
|
|
2385
|
+
source,
|
|
2386
|
+
void 0,
|
|
2387
|
+
onXml,
|
|
2388
|
+
onError,
|
|
2389
|
+
onError,
|
|
2390
|
+
enableParser
|
|
2391
|
+
);
|
|
2392
|
+
} catch (err) {
|
|
2393
|
+
resolve({
|
|
2394
|
+
success: false,
|
|
2395
|
+
message: `parseMermaidDiagram threw: ${err instanceof Error ? err.message : String(err)}`
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// src/tools/import-mermaid/v30.ts
|
|
2402
|
+
function import_mermaid2(ui2, options) {
|
|
2403
|
+
const validated = validateOptions(options);
|
|
2404
|
+
if ("success" in validated) return Promise.resolve(validated);
|
|
2405
|
+
const { source, mode, insertMode } = validated;
|
|
2406
|
+
if (typeof ui2?.parseMermaidDiagram !== "function") {
|
|
2407
|
+
return Promise.resolve({
|
|
2408
|
+
success: false,
|
|
2409
|
+
message: "ui.parseMermaidDiagram is not available; this Draw.io build does not expose Mermaid support."
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
return new Promise((resolve) => {
|
|
2413
|
+
const { onXml, onError } = runInsertFlow(ui2, mode, insertMode, resolve);
|
|
2414
|
+
try {
|
|
2415
|
+
if (mode === "embed" && typeof ui2.parseMermaidImage === "function") {
|
|
2416
|
+
ui2.parseMermaidImage(source, onXml, onError);
|
|
2417
|
+
} else {
|
|
2418
|
+
ui2.parseMermaidDiagram(source, void 0, onXml, onError, onError);
|
|
2419
|
+
}
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
resolve({
|
|
2422
|
+
success: false,
|
|
2423
|
+
message: `Mermaid API threw: ${err instanceof Error ? err.message : String(err)}`
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// src/drawio-compat/matrix.ts
|
|
2430
|
+
var COMPAT_MATRIX = {
|
|
2431
|
+
supportedFloor: "29.0.0",
|
|
2432
|
+
versionedTools: {
|
|
2433
|
+
"import-mermaid": [
|
|
2434
|
+
{
|
|
2435
|
+
range: { min: "29.0.0", maxExclusive: "30.0.0" },
|
|
2436
|
+
impl: import_mermaid
|
|
2437
|
+
},
|
|
2438
|
+
{
|
|
2439
|
+
range: { min: "30.0.0", maxExclusive: null },
|
|
2440
|
+
impl: import_mermaid2
|
|
2441
|
+
}
|
|
2442
|
+
]
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
|
|
2446
|
+
// src/tools/import-mermaid/index.ts
|
|
2447
|
+
var TOOL_NAME = "import-mermaid";
|
|
2448
|
+
function import_mermaid3(ui2, options) {
|
|
2449
|
+
const detected = getDetectedDrawioVersion(ui2);
|
|
2450
|
+
const outcome = dispatchTool(TOOL_NAME, detected, COMPAT_MATRIX);
|
|
2451
|
+
if (outcome === null) {
|
|
2452
|
+
return Promise.resolve({
|
|
2453
|
+
success: false,
|
|
2454
|
+
message: `import-mermaid has no matrix entry; refusing to guess.`
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
switch (outcome.kind) {
|
|
2458
|
+
case "matched":
|
|
2459
|
+
return outcome.impl(ui2, options);
|
|
2460
|
+
case "above-window": {
|
|
2461
|
+
const entries = COMPAT_MATRIX.versionedTools[TOOL_NAME] ?? [];
|
|
2462
|
+
const fallback = entries.at(-1);
|
|
2463
|
+
if (!fallback) {
|
|
2464
|
+
return Promise.resolve({
|
|
2465
|
+
success: false,
|
|
2466
|
+
message: `no impl available for drawio v${outcome.detected}`
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
return fallback.impl(ui2, options);
|
|
2470
|
+
}
|
|
2471
|
+
case "below-floor":
|
|
2472
|
+
return Promise.resolve({
|
|
2473
|
+
success: false,
|
|
2474
|
+
message: `drawio v${outcome.detected} predates supported floor v${outcome.floor}. Upgrade drawio.`
|
|
2475
|
+
});
|
|
2476
|
+
case "no-version":
|
|
2477
|
+
return Promise.resolve({
|
|
2478
|
+
success: false,
|
|
2479
|
+
message: `cannot detect drawio version (${outcome.reason}); pin a supported drawio build.`
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2307
2484
|
// src/tool-registry.ts
|
|
2308
2485
|
var VISIBLE_PAGE_EXECUTION = {
|
|
2309
2486
|
mode: "visible-page"
|
|
@@ -2583,7 +2760,7 @@ var DrawMcp = (() => {
|
|
|
2583
2760
|
page_tool({
|
|
2584
2761
|
name: "import-mermaid",
|
|
2585
2762
|
params: /* @__PURE__ */ new Set(["mermaid_source", "mode", "insert_mode", "target_page"]),
|
|
2586
|
-
handler:
|
|
2763
|
+
handler: import_mermaid3,
|
|
2587
2764
|
pageExecution: VISIBLE_PAGE_MUTATION_EXECUTION,
|
|
2588
2765
|
skip: skip_page_execution_for_new_page_mermaid
|
|
2589
2766
|
}),
|
|
@@ -2621,6 +2798,61 @@ var DrawMcp = (() => {
|
|
|
2621
2798
|
})
|
|
2622
2799
|
);
|
|
2623
2800
|
|
|
2801
|
+
// src/drawio-compat/report.ts
|
|
2802
|
+
function computeCompatReport(matrix = COMPAT_MATRIX) {
|
|
2803
|
+
const detected = getDetectedDrawioVersion();
|
|
2804
|
+
if (!detected.ok) {
|
|
2805
|
+
return {
|
|
2806
|
+
drawioVersion: detected.raw,
|
|
2807
|
+
state: "no-version",
|
|
2808
|
+
floor: matrix.supportedFloor,
|
|
2809
|
+
detail: detected.reason
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
if (isBelowFloor(detected.semver, matrix.supportedFloor)) {
|
|
2813
|
+
return {
|
|
2814
|
+
drawioVersion: detected.raw,
|
|
2815
|
+
state: "below-floor",
|
|
2816
|
+
floor: matrix.supportedFloor
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
const anyBounded = Object.values(matrix.versionedTools).some(
|
|
2820
|
+
(entries) => entries.at(-1)?.range.maxExclusive !== null
|
|
2821
|
+
);
|
|
2822
|
+
if (anyBounded) {
|
|
2823
|
+
const boundedFor = Object.entries(matrix.versionedTools).filter(
|
|
2824
|
+
([, entries]) => entries.at(-1)?.range.maxExclusive !== null
|
|
2825
|
+
);
|
|
2826
|
+
for (const [, entries] of boundedFor) {
|
|
2827
|
+
const newest = entries.at(-1);
|
|
2828
|
+
const max = parseVersion(newest.range.maxExclusive);
|
|
2829
|
+
if (max) {
|
|
2830
|
+
if (detected.semver[0] > max[0] || detected.semver[0] === max[0] && detected.semver[1] > max[1] || detected.semver[0] === max[0] && detected.semver[1] === max[1] && detected.semver[2] >= max[2]) {
|
|
2831
|
+
return {
|
|
2832
|
+
drawioVersion: detected.raw,
|
|
2833
|
+
state: "above-window",
|
|
2834
|
+
floor: matrix.supportedFloor,
|
|
2835
|
+
detail: newest.range.min
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return {
|
|
2842
|
+
drawioVersion: detected.raw,
|
|
2843
|
+
state: "ok",
|
|
2844
|
+
floor: matrix.supportedFloor
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
function sendCompatReport(send, log = console.log.bind(console)) {
|
|
2848
|
+
const report = computeCompatReport();
|
|
2849
|
+
send({ __control: "compat-report", ...report });
|
|
2850
|
+
log(
|
|
2851
|
+
`[drawio-mcp] drawio version: ${report.drawioVersion ?? "unknown"} \u2014 compat state: ${report.state} (floor: ${report.floor})`
|
|
2852
|
+
);
|
|
2853
|
+
return report;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2624
2856
|
// src/bootstrap.ts
|
|
2625
2857
|
var SHAPE_EXTRACTION_MAX_ATTEMPTS = 10;
|
|
2626
2858
|
var SHAPE_EXTRACTION_INTERVAL_MS = 1e3;
|
|
@@ -2822,6 +3054,7 @@ var DrawMcp = (() => {
|
|
|
2822
3054
|
cleanups.push(() => clearInterval(intervalId));
|
|
2823
3055
|
}
|
|
2824
3056
|
}
|
|
3057
|
+
sendCompatReport(transport2.send.bind(transport2));
|
|
2825
3058
|
return {
|
|
2826
3059
|
syncDocumentState,
|
|
2827
3060
|
dispose: () => {
|
package/build/register-tool.js
CHANGED
|
@@ -1,31 +1,83 @@
|
|
|
1
1
|
import { stripSchemaRecursively } from "./strip-schema.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* `$schema` from
|
|
3
|
+
* Wraps an `McpServer` so that the `tools/list` response strips the
|
|
4
|
+
* `$schema` key from every tool's `inputSchema` and `outputSchema`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* drop tools because the `$` character fails Anthropic's validation regex
|
|
8
|
-
* `^[a-zA-Z0-9_.-]{1,64}$`.
|
|
6
|
+
* Why this is necessary:
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - The MCP SDK lazily serializes tool schemas: it stores the Zod object
|
|
9
|
+
* verbatim at registration time and only converts to JSON Schema
|
|
10
|
+
* (via `zodToJsonSchema` / `z4mini.toJSONSchema`) when a client issues
|
|
11
|
+
* `tools/list`. The conversion emits a top-level
|
|
12
|
+
* `"$schema": "http://json-schema.org/draft-07/schema#"` field.
|
|
13
|
+
*
|
|
14
|
+
* - Claude Code's MCP ingest layer can't tolerate the `$` character in
|
|
15
|
+
* schema keys (Anthropic's parameter-name regex is
|
|
16
|
+
* `^[a-zA-Z0-9_.-]{1,64}$`). When `$schema` is present, the entire
|
|
17
|
+
* `properties` object is dropped, leaving tools effectively schemaless.
|
|
18
|
+
* The user can see the tool name but the LLM has no way to know which
|
|
19
|
+
* arguments to send, so calls fail at runtime.
|
|
20
|
+
*
|
|
21
|
+
* - A previous attempt at this fix tried to strip `$schema` from the
|
|
22
|
+
* stored `_registeredTools[name].inputSchema` via `setTimeout(0)`.
|
|
23
|
+
* That was a no-op against modern SDKs because the stored value at
|
|
24
|
+
* that point is the raw Zod object (which doesn't have `$schema`).
|
|
25
|
+
* The `$schema` key is added by `toJsonSchemaCompat` at serialization
|
|
26
|
+
* time, not at registration time.
|
|
27
|
+
*
|
|
28
|
+
* The fix here intercepts the `tools/list` request handler that the SDK
|
|
29
|
+
* registers internally on the first `server.tool(...)` call, and wraps
|
|
30
|
+
* it so the response runs through `stripSchemaRecursively` before being
|
|
31
|
+
* returned to the client.
|
|
12
32
|
*/
|
|
13
33
|
export function createServerWithSchemaStripping(server) {
|
|
14
|
-
// Store original tool method
|
|
15
34
|
const originalTool = server.tool.bind(server);
|
|
16
|
-
|
|
17
|
-
server.tool = function tool(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
}, 0);
|
|
35
|
+
let patched = false;
|
|
36
|
+
server.tool = function tool(...args) {
|
|
37
|
+
const result = originalTool(...args);
|
|
38
|
+
// The SDK lazily registers the tools/list handler on the first
|
|
39
|
+
// server.tool() call (via setToolRequestHandlers). After that call
|
|
40
|
+
// returns, the handler is in place and we can wrap it.
|
|
41
|
+
if (!patched) {
|
|
42
|
+
patched = true;
|
|
43
|
+
patchToolsListHandler(server);
|
|
44
|
+
}
|
|
28
45
|
return result;
|
|
29
46
|
};
|
|
30
47
|
return server;
|
|
31
48
|
}
|
|
49
|
+
function patchToolsListHandler(server) {
|
|
50
|
+
// The McpServer wraps an inner Server (the low-level protocol layer).
|
|
51
|
+
// Its `_requestHandlers` Map stores per-method handlers keyed by the
|
|
52
|
+
// method name literal (e.g. "tools/list").
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
const innerServer = server.server;
|
|
55
|
+
const handlers = innerServer?._requestHandlers;
|
|
56
|
+
if (!handlers) {
|
|
57
|
+
// SDK internals changed; fail closed (no-op) so we don't crash the
|
|
58
|
+
// server. The tools will still work but schemas will leak `$schema`.
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const original = handlers.get("tools/list");
|
|
62
|
+
if (!original) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
handlers.set("tools/list", async (...callArgs) => {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
const response = await original(...callArgs);
|
|
68
|
+
if (response && Array.isArray(response.tools)) {
|
|
69
|
+
response.tools = response.tools.map(stripToolSchemas);
|
|
70
|
+
}
|
|
71
|
+
return response;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function stripToolSchemas(tool) {
|
|
75
|
+
const next = { ...tool };
|
|
76
|
+
if (next.inputSchema) {
|
|
77
|
+
next.inputSchema = stripSchemaRecursively(next.inputSchema);
|
|
78
|
+
}
|
|
79
|
+
if (next.outputSchema) {
|
|
80
|
+
next.outputSchema = stripSchemaRecursively(next.outputSchema);
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
async function getFreePort() {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const srv = createServer();
|
|
10
|
+
srv.once("error", reject);
|
|
11
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
12
|
+
const addr = srv.address();
|
|
13
|
+
if (addr && typeof addr === "object") {
|
|
14
|
+
const port = addr.port;
|
|
15
|
+
srv.close(() => resolve(port));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
srv.close(() => reject(new Error("could not resolve free port")));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function isPortFree(port, host = "127.0.0.1") {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const srv = createServer();
|
|
26
|
+
srv.once("error", () => resolve(false));
|
|
27
|
+
srv.listen({ port, host }, () => {
|
|
28
|
+
srv.close(() => resolve(true));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function waitForPortHeld(port, host, timeoutMs) {
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
const free = await isPortFree(port, host);
|
|
36
|
+
if (!free)
|
|
37
|
+
return true;
|
|
38
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
async function waitForExit(proc, timeoutMs) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
45
|
+
proc.once("exit", (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve(code ?? 0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const SERVER_BIN = join(here, "index.js");
|
|
53
|
+
const HOST = "127.0.0.1";
|
|
54
|
+
function spawnServer(extensionPort, httpPort, transport = "stdio") {
|
|
55
|
+
return spawn(process.execPath, [
|
|
56
|
+
SERVER_BIN,
|
|
57
|
+
"--transport",
|
|
58
|
+
transport,
|
|
59
|
+
"--extension-port",
|
|
60
|
+
String(extensionPort),
|
|
61
|
+
"--http-port",
|
|
62
|
+
String(httpPort),
|
|
63
|
+
"--host",
|
|
64
|
+
HOST,
|
|
65
|
+
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
66
|
+
}
|
|
67
|
+
describe("graceful shutdown releases WebSocket port", () => {
|
|
68
|
+
let proc;
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
if (proc && !proc.killed) {
|
|
71
|
+
proc.kill("SIGKILL");
|
|
72
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
73
|
+
}
|
|
74
|
+
proc = undefined;
|
|
75
|
+
});
|
|
76
|
+
it("releases extension port after SIGTERM", async () => {
|
|
77
|
+
const extensionPort = await getFreePort();
|
|
78
|
+
const httpPort = await getFreePort();
|
|
79
|
+
proc = spawnServer(extensionPort, httpPort);
|
|
80
|
+
const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
|
|
81
|
+
expect(portTaken).toBe(true);
|
|
82
|
+
proc.kill("SIGTERM");
|
|
83
|
+
const exitCode = await waitForExit(proc, 5000);
|
|
84
|
+
expect(exitCode).not.toBeNull();
|
|
85
|
+
const free = await isPortFree(extensionPort, HOST);
|
|
86
|
+
expect(free).toBe(true);
|
|
87
|
+
}, 20000);
|
|
88
|
+
it("releases extension port after SIGINT", async () => {
|
|
89
|
+
const extensionPort = await getFreePort();
|
|
90
|
+
const httpPort = await getFreePort();
|
|
91
|
+
proc = spawnServer(extensionPort, httpPort);
|
|
92
|
+
const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
|
|
93
|
+
expect(portTaken).toBe(true);
|
|
94
|
+
proc.kill("SIGINT");
|
|
95
|
+
const exitCode = await waitForExit(proc, 5000);
|
|
96
|
+
expect(exitCode).not.toBeNull();
|
|
97
|
+
const free = await isPortFree(extensionPort, HOST);
|
|
98
|
+
expect(free).toBe(true);
|
|
99
|
+
}, 20000);
|
|
100
|
+
it("releases HTTP and extension ports after SIGTERM in http transport", async () => {
|
|
101
|
+
const extensionPort = await getFreePort();
|
|
102
|
+
const httpPort = await getFreePort();
|
|
103
|
+
proc = spawnServer(extensionPort, httpPort, "http");
|
|
104
|
+
const extTaken = await waitForPortHeld(extensionPort, HOST, 5000);
|
|
105
|
+
const httpTaken = await waitForPortHeld(httpPort, HOST, 5000);
|
|
106
|
+
expect(extTaken).toBe(true);
|
|
107
|
+
expect(httpTaken).toBe(true);
|
|
108
|
+
proc.kill("SIGTERM");
|
|
109
|
+
const exitCode = await waitForExit(proc, 5000);
|
|
110
|
+
expect(exitCode).not.toBeNull();
|
|
111
|
+
expect(await isPortFree(extensionPort, HOST)).toBe(true);
|
|
112
|
+
expect(await isPortFree(httpPort, HOST)).toBe(true);
|
|
113
|
+
}, 20000);
|
|
114
|
+
it("releases extension port after SIGINT with a live WebSocket client", async () => {
|
|
115
|
+
const extensionPort = await getFreePort();
|
|
116
|
+
const httpPort = await getFreePort();
|
|
117
|
+
proc = spawnServer(extensionPort, httpPort);
|
|
118
|
+
const portTaken = await waitForPortHeld(extensionPort, HOST, 5000);
|
|
119
|
+
expect(portTaken).toBe(true);
|
|
120
|
+
const ws = new WebSocket(`ws://${HOST}:${extensionPort}`);
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
ws.once("open", () => resolve());
|
|
123
|
+
ws.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
proc.kill("SIGINT");
|
|
126
|
+
const exitCode = await waitForExit(proc, 5000);
|
|
127
|
+
expect(exitCode).not.toBeNull();
|
|
128
|
+
try {
|
|
129
|
+
ws.terminate();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
const free = await isPortFree(extensionPort, HOST);
|
|
135
|
+
expect(free).toBe(true);
|
|
136
|
+
}, 20000);
|
|
137
|
+
it("releases extension port when stdio host closes stdin pipe", 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
|
+
proc.stdin.end();
|
|
144
|
+
const exitCode = await waitForExit(proc, 5000);
|
|
145
|
+
expect(exitCode).not.toBeNull();
|
|
146
|
+
const free = await isPortFree(extensionPort, HOST);
|
|
147
|
+
expect(free).toBe(true);
|
|
148
|
+
}, 20000);
|
|
149
|
+
});
|