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.
Files changed (71) hide show
  1. package/README.md +29 -3
  2. package/build/assets/downloader.js +16 -17
  3. package/build/config.js +253 -5
  4. package/build/config.test.js +449 -1
  5. package/build/emitter_bus.js +3 -0
  6. package/build/emitter_bus.test.js +7 -0
  7. package/build/index.capabilities.test.js +45 -0
  8. package/build/index.js +416 -82
  9. package/build/install-desktop-plugin.js +56 -0
  10. package/build/install-desktop-plugin.test.js +66 -0
  11. package/build/multi-transport.test.js +117 -0
  12. package/build/plugin/mcp-plugin.js +2180 -1105
  13. package/build/prefetch-assets.js +6 -5
  14. package/build/real-environment/document-targeting.test.js +124 -0
  15. package/build/real-environment/export-diagram.test.js +1 -1
  16. package/build/real-environment/harness.js +118 -31
  17. package/build/real-environment/import-mermaid.test.js +81 -0
  18. package/build/real-environment/pages-and-concurrency.test.js +448 -0
  19. package/build/real-environment/shapes.test.js +58 -0
  20. package/build/real-environment/tools.js +51 -6
  21. package/build/register-tool.js +31 -0
  22. package/build/request_queue.js +29 -0
  23. package/build/request_queue.test.js +98 -0
  24. package/build/stdio-transport-purity.test.js +88 -0
  25. package/build/strip-schema.js +61 -0
  26. package/build/tls/expiry.js +14 -0
  27. package/build/tls/expiry.test.js +54 -0
  28. package/build/tls/generate.js +123 -0
  29. package/build/tls/generate.test.js +115 -0
  30. package/build/tls/index.js +80 -0
  31. package/build/tls/index.test.js +141 -0
  32. package/build/tls/install-hint.js +45 -0
  33. package/build/tls/install-hint.test.js +32 -0
  34. package/build/tls/load.js +18 -0
  35. package/build/tls/load.test.js +40 -0
  36. package/build/tls/paths.js +30 -0
  37. package/build/tls/paths.test.js +53 -0
  38. package/build/tls/san.js +27 -0
  39. package/build/tls/san.test.js +72 -0
  40. package/build/tool-registry.test.js +823 -0
  41. package/build/tool.js +74 -15
  42. package/build/tool.test.js +250 -7
  43. package/build/tools/add-cell-of-shape.js +4 -2
  44. package/build/tools/add-edge.js +3 -1
  45. package/build/tools/add-rectangle.js +4 -2
  46. package/build/tools/copy-page.js +13 -0
  47. package/build/tools/create-layer.js +4 -2
  48. package/build/tools/create-page.js +8 -0
  49. package/build/tools/delete-cell-by-id.js +4 -2
  50. package/build/tools/edit-cell.js +3 -1
  51. package/build/tools/edit-edge.js +3 -1
  52. package/build/tools/export-diagram.js +7 -3
  53. package/build/tools/get-active-layer.js +4 -1
  54. package/build/tools/get-current-page.js +5 -0
  55. package/build/tools/get-selected-cell.js +4 -1
  56. package/build/tools/import-diagram.js +12 -2
  57. package/build/tools/import-mermaid.js +31 -0
  58. package/build/tools/index.js +14 -0
  59. package/build/tools/list-documents.js +17 -0
  60. package/build/tools/list-layers.js +4 -1
  61. package/build/tools/list-paged-model.js +4 -3
  62. package/build/tools/list-pages.js +5 -0
  63. package/build/tools/move-cell-to-layer.js +3 -1
  64. package/build/tools/rename-page.js +10 -0
  65. package/build/tools/set-active-layer.js +4 -2
  66. package/build/tools/set-cell-data.js +3 -1
  67. package/build/tools/set-cell-parent.js +3 -1
  68. package/build/tools/set-cell-shape.js +3 -1
  69. package/build/tools/shared.js +35 -0
  70. package/build/tools/shared.test.js +29 -0
  71. package/package.json +12 -4
@@ -0,0 +1,448 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
+ import { createRealEnvironmentContext, disposeRealEnvironmentContext, resetDiagram, } from "./harness.js";
6
+ import { expectNoBrowserErrors, expectNoServerErrors } from "./assertions.js";
7
+ import { callClientToolRaw, callClientToolJson, callToolJson, callToolRaw, } from "./tools.js";
8
+ import { expectToolSuccess, unwrapToolPayload } from "./test-helpers.js";
9
+ function extractPrimaryText(result) {
10
+ return (result.content.find((item) => item.type === "text" &&
11
+ (item.text?.includes("mxGraphModel") || item.text?.includes("<svg")))?.text ?? "");
12
+ }
13
+ async function createHttpClient(context, name) {
14
+ const client = new Client({
15
+ name,
16
+ version: "1.0.0",
17
+ });
18
+ const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${context.httpPort}/mcp`));
19
+ await client.connect(transport);
20
+ return client;
21
+ }
22
+ describe("real environment/pages and concurrency", () => {
23
+ let context;
24
+ beforeAll(async () => {
25
+ context = await createRealEnvironmentContext();
26
+ }, 180000);
27
+ afterAll(async () => {
28
+ await disposeRealEnvironmentContext(context);
29
+ });
30
+ it("supports page listing and serializes concurrent page-targeted edits", async () => {
31
+ await resetDiagram(context);
32
+ context.browserMessages.length = 0;
33
+ const logCountBefore = context.logger.entries.length;
34
+ const { payload: renamedFirstPayload } = await callToolJson(context, "rename-page", {
35
+ page: { index: 0 },
36
+ name: "Page Alpha",
37
+ });
38
+ expectToolSuccess(renamedFirstPayload);
39
+ const pageAlpha = unwrapToolPayload(renamedFirstPayload);
40
+ expect(pageAlpha.is_current).toBe(true);
41
+ const { payload: createdPayload } = await callToolJson(context, "create-page", {
42
+ name: "Page Beta Draft",
43
+ });
44
+ expectToolSuccess(createdPayload);
45
+ const pageBeta = unwrapToolPayload(createdPayload);
46
+ expect(pageBeta.is_current).toBe(false);
47
+ const { payload: currentAfterCreatePayload } = await callToolJson(context, "get-current-page", {});
48
+ expectToolSuccess(currentAfterCreatePayload);
49
+ const currentAfterCreate = unwrapToolPayload(currentAfterCreatePayload);
50
+ expect(currentAfterCreate.id).toBe(pageAlpha.id);
51
+ const { payload: renamedBetaPayload } = await callToolJson(context, "rename-page", {
52
+ page: { id: pageBeta.id },
53
+ name: "Page Beta",
54
+ });
55
+ expectToolSuccess(renamedBetaPayload);
56
+ const renamedBeta = unwrapToolPayload(renamedBetaPayload);
57
+ expect(renamedBeta).toMatchObject({
58
+ id: pageBeta.id,
59
+ name: "Page Beta",
60
+ is_current: false,
61
+ });
62
+ const { payload: currentAfterRenamePayload } = await callToolJson(context, "get-current-page", {});
63
+ expectToolSuccess(currentAfterRenamePayload);
64
+ const currentAfterRename = unwrapToolPayload(currentAfterRenamePayload);
65
+ expect(currentAfterRename.id).toBe(pageAlpha.id);
66
+ 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="gamma-seed" value="Gamma Seed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1"><mxGeometry x="80" y="120" width="180" height="80" as="geometry"/></mxCell></root></mxGraphModel>';
67
+ const { payload: importPayload } = await callToolJson(context, "import-diagram", {
68
+ data: importXml,
69
+ format: "xml",
70
+ mode: "new-page",
71
+ filename: "Page Gamma.drawio",
72
+ });
73
+ expectToolSuccess(importPayload);
74
+ const importResult = unwrapToolPayload(importPayload);
75
+ expect(importResult.success).toBe(true);
76
+ expect(importResult.pages).toBe(3);
77
+ const { payload: pagesPayload } = await callToolJson(context, "list-pages", {});
78
+ expectToolSuccess(pagesPayload);
79
+ const pages = unwrapToolPayload(pagesPayload);
80
+ expect(pages).toEqual([
81
+ {
82
+ index: 0,
83
+ id: pageAlpha.id,
84
+ name: "Page Alpha",
85
+ is_current: false,
86
+ },
87
+ {
88
+ index: 1,
89
+ id: pageBeta.id,
90
+ name: "Page Beta",
91
+ is_current: false,
92
+ },
93
+ {
94
+ index: 2,
95
+ id: pages[2].id,
96
+ name: "Page Gamma",
97
+ is_current: true,
98
+ },
99
+ ]);
100
+ const missingTargetPage = await context.client.callTool({
101
+ name: "add-rectangle",
102
+ arguments: {
103
+ text: "Missing target page",
104
+ },
105
+ });
106
+ expect(missingTargetPage.isError).toBe(true);
107
+ expect(missingTargetPage.content[0]
108
+ ?.text).toContain("target_page");
109
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
110
+ const secondServer = context.app.createMcpServer();
111
+ const secondClient = new Client({
112
+ name: "page-concurrency-client",
113
+ version: "1.0.0",
114
+ });
115
+ await Promise.all([
116
+ secondServer.connect(serverTransport),
117
+ secondClient.connect(clientTransport),
118
+ ]);
119
+ try {
120
+ const { payload: alphaRectPayload } = await callToolJson(context, "add-rectangle", {
121
+ target_page: { id: pageAlpha.id },
122
+ x: 80,
123
+ y: 80,
124
+ width: 180,
125
+ height: 80,
126
+ text: "Alpha Seed",
127
+ });
128
+ expectToolSuccess(alphaRectPayload);
129
+ const alphaRect = unwrapToolPayload(alphaRectPayload);
130
+ const { payload: betaRectPayload } = await callClientToolJson(secondClient, "add-rectangle", {
131
+ target_page: { id: pageBeta.id },
132
+ x: 120,
133
+ y: 120,
134
+ width: 180,
135
+ height: 80,
136
+ text: "Beta Seed",
137
+ });
138
+ expectToolSuccess(betaRectPayload);
139
+ const betaRect = unwrapToolPayload(betaRectPayload);
140
+ await Promise.all([
141
+ callToolJson(context, "edit-cell", {
142
+ target_page: { id: pageAlpha.id },
143
+ cell_id: alphaRect.id,
144
+ text: "Alpha Updated",
145
+ }),
146
+ callClientToolJson(secondClient, "edit-cell", {
147
+ target_page: { id: pageBeta.id },
148
+ cell_id: betaRect.id,
149
+ text: "Beta Updated",
150
+ }),
151
+ ]);
152
+ const { payload: betaLayerPayload } = await callClientToolJson(secondClient, "create-layer", {
153
+ target_page: { id: pageBeta.id },
154
+ name: "Beta Background Layer",
155
+ });
156
+ expectToolSuccess(betaLayerPayload);
157
+ const betaLayer = unwrapToolPayload(betaLayerPayload);
158
+ await callClientToolJson(secondClient, "move-cell-to-layer", {
159
+ target_page: { id: pageBeta.id },
160
+ cell_id: betaRect.id,
161
+ target_layer_id: betaLayer.id,
162
+ });
163
+ const { payload: betaModelPayload } = await callClientToolJson(secondClient, "list-paged-model", {
164
+ target_page: { id: pageBeta.id },
165
+ filter: {
166
+ ids: [betaRect.id],
167
+ },
168
+ page: 0,
169
+ page_size: 10,
170
+ });
171
+ expectToolSuccess(betaModelPayload);
172
+ const betaModel = unwrapToolPayload(betaModelPayload);
173
+ const betaCells = Array.isArray(betaModel?.cells)
174
+ ? betaModel.cells
175
+ : Array.isArray(betaModel)
176
+ ? betaModel
177
+ : [];
178
+ expect(betaCells).toHaveLength(1);
179
+ expect(betaCells[0]?.layer?.id).toBe(betaLayer.id);
180
+ }
181
+ finally {
182
+ await secondClient.close();
183
+ }
184
+ const { payload: currentPagePayload } = await callToolJson(context, "get-current-page", {});
185
+ expectToolSuccess(currentPagePayload);
186
+ const currentPage = unwrapToolPayload(currentPagePayload);
187
+ expect(currentPage.id).toBe(pages[2].id);
188
+ const alphaExport = await callToolRaw(context, "export-diagram", {
189
+ target_page: { id: pageAlpha.id },
190
+ format: "xml",
191
+ size: "page",
192
+ });
193
+ const betaExport = await callToolRaw(context, "export-diagram", {
194
+ target_page: { id: pageBeta.id },
195
+ format: "xml",
196
+ size: "page",
197
+ });
198
+ const gammaExport = await callToolRaw(context, "export-diagram", {
199
+ target_page: { id: pages[2].id },
200
+ format: "xml",
201
+ size: "page",
202
+ });
203
+ const alphaXml = extractPrimaryText(alphaExport);
204
+ const betaXml = extractPrimaryText(betaExport);
205
+ const gammaXml = extractPrimaryText(gammaExport);
206
+ expect(alphaXml).toContain("Alpha Updated");
207
+ expect(alphaXml).not.toContain("Beta Updated");
208
+ expect(alphaXml).not.toContain("Gamma Seed");
209
+ expect(betaXml).toContain("Beta Updated");
210
+ expect(betaXml).not.toContain("Alpha Updated");
211
+ expect(betaXml).not.toContain("Gamma Seed");
212
+ expect(gammaXml).toContain("Gamma Seed");
213
+ expect(gammaXml).not.toContain("Alpha Updated");
214
+ expect(gammaXml).not.toContain("Beta Updated");
215
+ await callToolJson(context, "get-selected-cell", {
216
+ target_page: { id: pageAlpha.id },
217
+ });
218
+ const { payload: currentAfterVisibleToolPayload } = await callToolJson(context, "get-current-page", {});
219
+ expectToolSuccess(currentAfterVisibleToolPayload);
220
+ const currentAfterVisibleTool = unwrapToolPayload(currentAfterVisibleToolPayload);
221
+ expect(currentAfterVisibleTool.id).toBe(pageAlpha.id);
222
+ const betaSvgExport = await callToolRaw(context, "export-diagram", {
223
+ target_page: { id: pageBeta.id },
224
+ format: "svg",
225
+ size: "page",
226
+ embed_xml: true,
227
+ });
228
+ const betaSvg = extractPrimaryText(betaSvgExport);
229
+ expect(betaSvg).toContain("<svg");
230
+ expect(betaSvg).toContain("Beta Updated");
231
+ expect(betaSvg).not.toContain("Alpha Updated");
232
+ const { payload: currentAfterEmbeddedExportPayload } = await callToolJson(context, "get-current-page", {});
233
+ expectToolSuccess(currentAfterEmbeddedExportPayload);
234
+ const currentAfterEmbeddedExport = unwrapToolPayload(currentAfterEmbeddedExportPayload);
235
+ expect(currentAfterEmbeddedExport.id).toBe(pageAlpha.id);
236
+ await expectNoBrowserErrors(context, "pages-and-concurrency");
237
+ await expectNoServerErrors(context, "pages-and-concurrency", logCountBefore);
238
+ }, 180000);
239
+ it("copies a page for derivative edits without changing the source page", async () => {
240
+ await resetDiagram(context);
241
+ context.browserMessages.length = 0;
242
+ const logCountBefore = context.logger.entries.length;
243
+ const { payload: renamedSourcePayload } = await callToolJson(context, "rename-page", {
244
+ page: { index: 0 },
245
+ name: "Template Page",
246
+ });
247
+ expectToolSuccess(renamedSourcePayload);
248
+ const sourcePage = unwrapToolPayload(renamedSourcePayload);
249
+ await callToolJson(context, "add-rectangle", {
250
+ target_page: { id: sourcePage.id },
251
+ x: 100,
252
+ y: 120,
253
+ width: 210,
254
+ height: 90,
255
+ text: "Template Seed",
256
+ });
257
+ const { payload: tailPagePayload } = await callToolJson(context, "create-page", {
258
+ name: "Existing Tail Page",
259
+ });
260
+ expectToolSuccess(tailPagePayload);
261
+ const tailPage = unwrapToolPayload(tailPagePayload);
262
+ const { payload: pagesBeforeCopyPayload } = await callToolJson(context, "list-pages", {});
263
+ expectToolSuccess(pagesBeforeCopyPayload);
264
+ const pagesBeforeCopy = unwrapToolPayload(pagesBeforeCopyPayload);
265
+ const { payload: copiedPagePayload } = await callToolJson(context, "copy-page", {
266
+ page: { id: sourcePage.id },
267
+ name: "Template Variant",
268
+ });
269
+ expectToolSuccess(copiedPagePayload);
270
+ const copiedPage = unwrapToolPayload(copiedPagePayload);
271
+ expect(copiedPage).toMatchObject({
272
+ name: "Template Variant",
273
+ is_current: false,
274
+ });
275
+ const { payload: pagesAfterCopyPayload } = await callToolJson(context, "list-pages", {});
276
+ expectToolSuccess(pagesAfterCopyPayload);
277
+ const pagesAfterCopy = unwrapToolPayload(pagesAfterCopyPayload);
278
+ expect(pagesAfterCopy.map((page) => page.id)).toEqual([
279
+ ...pagesBeforeCopy.map((page) => page.id),
280
+ copiedPage.id,
281
+ ]);
282
+ expect(copiedPage.index).toBe(pagesBeforeCopy.length);
283
+ expect(pagesAfterCopy.find((page) => page.id === tailPage.id)?.index).toBe(tailPage.index);
284
+ const { payload: currentAfterCopyPayload } = await callToolJson(context, "get-current-page", {});
285
+ expectToolSuccess(currentAfterCopyPayload);
286
+ const currentAfterCopy = unwrapToolPayload(currentAfterCopyPayload);
287
+ expect(currentAfterCopy.id).toBe(sourcePage.id);
288
+ await callToolJson(context, "add-rectangle", {
289
+ target_page: { id: copiedPage.id },
290
+ x: 360,
291
+ y: 120,
292
+ width: 210,
293
+ height: 90,
294
+ text: "Variant Only",
295
+ });
296
+ const sourceExport = await callToolRaw(context, "export-diagram", {
297
+ target_page: { id: sourcePage.id },
298
+ format: "xml",
299
+ size: "page",
300
+ });
301
+ const copiedExport = await callToolRaw(context, "export-diagram", {
302
+ target_page: { id: copiedPage.id },
303
+ format: "xml",
304
+ size: "page",
305
+ });
306
+ const sourceXml = extractPrimaryText(sourceExport);
307
+ const copiedXml = extractPrimaryText(copiedExport);
308
+ expect(sourceXml).toContain("Template Seed");
309
+ expect(sourceXml).not.toContain("Variant Only");
310
+ expect(copiedXml).toContain("Template Seed");
311
+ expect(copiedXml).toContain("Variant Only");
312
+ await expectNoBrowserErrors(context, "copy-page");
313
+ await expectNoServerErrors(context, "copy-page", logCountBefore);
314
+ }, 180000);
315
+ });
316
+ describe("real environment/pages and concurrency over HTTP", () => {
317
+ let context;
318
+ beforeAll(async () => {
319
+ context = await createRealEnvironmentContext();
320
+ }, 180000);
321
+ afterAll(async () => {
322
+ await disposeRealEnvironmentContext(context);
323
+ });
324
+ it("serializes concurrent writes from three HTTP clients across three pages", async () => {
325
+ await resetDiagram(context);
326
+ context.browserMessages.length = 0;
327
+ const logCountBefore = context.logger.entries.length;
328
+ const { payload: renamedFirstPayload } = await callToolJson(context, "rename-page", {
329
+ page: { index: 0 },
330
+ name: "HTTP Page One",
331
+ });
332
+ expectToolSuccess(renamedFirstPayload);
333
+ const pageOne = unwrapToolPayload(renamedFirstPayload);
334
+ expect(pageOne.is_current).toBe(true);
335
+ const { payload: createdSecondPayload } = await callToolJson(context, "create-page", {
336
+ name: "HTTP Page Two",
337
+ });
338
+ expectToolSuccess(createdSecondPayload);
339
+ const pageTwo = unwrapToolPayload(createdSecondPayload);
340
+ expect(pageTwo.is_current).toBe(false);
341
+ const { payload: createdThirdPayload } = await callToolJson(context, "create-page", {
342
+ name: "HTTP Page Three",
343
+ });
344
+ expectToolSuccess(createdThirdPayload);
345
+ const pageThree = unwrapToolPayload(createdThirdPayload);
346
+ expect(pageThree.is_current).toBe(false);
347
+ const { payload: currentAfterCreatePayload } = await callToolJson(context, "get-current-page", {});
348
+ expectToolSuccess(currentAfterCreatePayload);
349
+ const currentAfterCreate = unwrapToolPayload(currentAfterCreatePayload);
350
+ expect(currentAfterCreate.id).toBe(pageOne.id);
351
+ const clients = [
352
+ await createHttpClient(context, "http-page-client-1"),
353
+ await createHttpClient(context, "http-page-client-2"),
354
+ await createHttpClient(context, "http-page-client-3"),
355
+ ];
356
+ try {
357
+ const [clientOne, clientTwo, clientThree] = clients;
358
+ const [pageOneRectPayload, pageTwoRectPayload, pageThreeRectPayload] = await Promise.all([
359
+ callClientToolJson(clientOne, "add-rectangle", {
360
+ target_page: { id: pageOne.id },
361
+ x: 60,
362
+ y: 80,
363
+ width: 210,
364
+ height: 90,
365
+ text: "HTTP One Seed",
366
+ }),
367
+ callClientToolJson(clientTwo, "add-rectangle", {
368
+ target_page: { id: pageTwo.id },
369
+ x: 100,
370
+ y: 120,
371
+ width: 210,
372
+ height: 90,
373
+ text: "HTTP Two Seed",
374
+ }),
375
+ callClientToolJson(clientThree, "add-rectangle", {
376
+ target_page: { id: pageThree.id },
377
+ x: 140,
378
+ y: 160,
379
+ width: 210,
380
+ height: 90,
381
+ text: "HTTP Three Seed",
382
+ }),
383
+ ]);
384
+ const pageOneRect = unwrapToolPayload(pageOneRectPayload.payload);
385
+ const pageTwoRect = unwrapToolPayload(pageTwoRectPayload.payload);
386
+ const pageThreeRect = unwrapToolPayload(pageThreeRectPayload.payload);
387
+ await Promise.all([
388
+ callClientToolJson(clientOne, "edit-cell", {
389
+ target_page: { id: pageOne.id },
390
+ cell_id: pageOneRect.id,
391
+ text: "HTTP One Final",
392
+ }),
393
+ callClientToolJson(clientTwo, "edit-cell", {
394
+ target_page: { id: pageTwo.id },
395
+ cell_id: pageTwoRect.id,
396
+ text: "HTTP Two Final",
397
+ }),
398
+ callClientToolJson(clientThree, "edit-cell", {
399
+ target_page: { id: pageThree.id },
400
+ cell_id: pageThreeRect.id,
401
+ text: "HTTP Three Final",
402
+ }),
403
+ ]);
404
+ const { payload: currentPagePayload } = await callToolJson(context, "get-current-page", {});
405
+ expectToolSuccess(currentPagePayload);
406
+ const currentPage = unwrapToolPayload(currentPagePayload);
407
+ expect(currentPage.id).toBe(pageOne.id);
408
+ const [pageOneExport, pageTwoExport, pageThreeExport] = await Promise.all([
409
+ callClientToolRaw(clientOne, "export-diagram", {
410
+ target_page: { id: pageOne.id },
411
+ format: "xml",
412
+ size: "page",
413
+ }),
414
+ callClientToolRaw(clientTwo, "export-diagram", {
415
+ target_page: { id: pageTwo.id },
416
+ format: "xml",
417
+ size: "page",
418
+ }),
419
+ callClientToolRaw(clientThree, "export-diagram", {
420
+ target_page: { id: pageThree.id },
421
+ format: "xml",
422
+ size: "page",
423
+ }),
424
+ ]);
425
+ const pageOneXml = extractPrimaryText(pageOneExport);
426
+ const pageTwoXml = extractPrimaryText(pageTwoExport);
427
+ const pageThreeXml = extractPrimaryText(pageThreeExport);
428
+ expect(pageOneXml).toContain("HTTP One Final");
429
+ expect(pageOneXml).not.toContain("HTTP Two Final");
430
+ expect(pageOneXml).not.toContain("HTTP Three Final");
431
+ expect(pageTwoXml).toContain("HTTP Two Final");
432
+ expect(pageTwoXml).not.toContain("HTTP One Final");
433
+ expect(pageTwoXml).not.toContain("HTTP Three Final");
434
+ expect(pageThreeXml).toContain("HTTP Three Final");
435
+ expect(pageThreeXml).not.toContain("HTTP One Final");
436
+ expect(pageThreeXml).not.toContain("HTTP Two Final");
437
+ const { payload: currentAfterExportsPayload } = await callToolJson(context, "get-current-page", {});
438
+ expectToolSuccess(currentAfterExportsPayload);
439
+ const currentAfterExports = unwrapToolPayload(currentAfterExportsPayload);
440
+ expect(currentAfterExports.id).toBe(pageOne.id);
441
+ }
442
+ finally {
443
+ await Promise.all(clients.map((client) => client.close()));
444
+ }
445
+ await expectNoBrowserErrors(context, "pages-and-concurrency-http");
446
+ await expectNoServerErrors(context, "pages-and-concurrency-http", logCountBefore);
447
+ }, 180000);
448
+ });
@@ -150,4 +150,62 @@ describe("real environment/shapes", () => {
150
150
  await expectNoBrowserErrors(context, "shapes-aws-lambda-set-cell-shape");
151
151
  await expectNoServerErrors(context, "shapes-aws-lambda-set-cell-shape", logCountBefore);
152
152
  }, 180000);
153
+ const vendorCases = [
154
+ {
155
+ label: "GCP search marker",
156
+ shape_name: "mxgraph.gcp2.search",
157
+ keyFragment: "mxgraph.gcp2.search",
158
+ },
159
+ {
160
+ label: "Azure compute Virtual_Machine",
161
+ shape_name: "mxgraph.azure2.compute.virtual_machine",
162
+ keyFragment: "Virtual_Machine.svg",
163
+ },
164
+ {
165
+ label: "Cisco19 l2_switch",
166
+ shape_name: "mxgraph.cisco19.l2_switch",
167
+ keyFragment: "prIcon=l2_switch",
168
+ },
169
+ {
170
+ label: "CiscoSafe capability anti_malware",
171
+ shape_name: "mxgraph.cisco_safe.capability.anti_malware",
172
+ keyFragment: "mxgraph.cisco_safe.capability.anti_malware",
173
+ },
174
+ ];
175
+ for (const vendorCase of vendorCases) {
176
+ it(`resolves and renders ${vendorCase.label} from runtime-extracted catalog`, async () => {
177
+ await resetDiagram(context);
178
+ context.browserMessages.length = 0;
179
+ const logCountBefore = context.logger.entries.length;
180
+ const { payload: shapeByNamePayload } = await callToolJson(context, "get-shape-by-name", { shape_name: vendorCase.shape_name });
181
+ const shapeByName = unwrapToolPayload(shapeByNamePayload);
182
+ expect(shapeByName).toBeDefined();
183
+ expect(shapeByName).not.toBeNull();
184
+ expect(String(shapeByName?.style ?? "")).toContain(vendorCase.keyFragment);
185
+ const { payload } = await callToolJson(context, "add-cell-of-shape", {
186
+ shape_name: vendorCase.shape_name,
187
+ text: vendorCase.label,
188
+ x: 200,
189
+ y: 160,
190
+ width: 120,
191
+ height: 120,
192
+ });
193
+ expectToolSuccess(payload);
194
+ const screenshotLabel = `shapes-${vendorCase.shape_name.replace(/[^a-z0-9]+/gi, "-")}`;
195
+ await withVerificationScreenshot(context, screenshotLabel, "before-live-state-verification", async () => {
196
+ const exportedXml = await context.page.evaluate(() => {
197
+ const maybeWindow = window;
198
+ const editor = maybeWindow.ui?.editor;
199
+ const xmlNode = editor?.getGraphXml?.();
200
+ return window.mxUtils?.getXml?.(xmlNode) ?? "";
201
+ });
202
+ const cell = await getCellById(context.page, payload.result.id);
203
+ expect(cell).not.toBeNull();
204
+ expect(exportedXml).toContain(`id="${payload.result.id}"`);
205
+ expect(exportedXml).toContain(vendorCase.keyFragment);
206
+ });
207
+ await expectNoBrowserErrors(context, screenshotLabel);
208
+ await expectNoServerErrors(context, screenshotLabel, logCountBefore);
209
+ }, 180000);
210
+ }
153
211
  });
@@ -1,8 +1,47 @@
1
1
  import { expect } from "@jest/globals";
2
- export async function callToolJson(context, name, args) {
3
- const result = (await context.client.callTool({
2
+ const PAGE_SCOPED_TOOL_NAMES = new Set([
3
+ "get-selected-cell",
4
+ "add-rectangle",
5
+ "add-edge",
6
+ "delete-cell-by-id",
7
+ "add-cell-of-shape",
8
+ "set-cell-shape",
9
+ "set-cell-data",
10
+ "edit-cell",
11
+ "edit-edge",
12
+ "list-paged-model",
13
+ "list-layers",
14
+ "set-active-layer",
15
+ "move-cell-to-layer",
16
+ "get-active-layer",
17
+ "create-layer",
18
+ "set-cell-parent",
19
+ "export-diagram",
20
+ "import-diagram",
21
+ "import-mermaid",
22
+ ]);
23
+ function withDefaultTargetPage(name, args) {
24
+ if (!PAGE_SCOPED_TOOL_NAMES.has(name)) {
25
+ return args;
26
+ }
27
+ if (name === "import-diagram" && args.mode === "new-page") {
28
+ return args;
29
+ }
30
+ if (name === "import-mermaid" && args.insert_mode === "new-page") {
31
+ return args;
32
+ }
33
+ if (args.target_page) {
34
+ return args;
35
+ }
36
+ return {
37
+ target_page: { index: 0 },
38
+ ...args,
39
+ };
40
+ }
41
+ export async function callClientToolJson(client, name, args) {
42
+ const result = (await client.callTool({
4
43
  name,
5
- arguments: args,
44
+ arguments: withDefaultTargetPage(name, args),
6
45
  }));
7
46
  expect(result.isError).not.toBe(true);
8
47
  const content = result.content;
@@ -12,11 +51,17 @@ export async function callToolJson(context, name, args) {
12
51
  payload: JSON.parse(String(content[0]?.text ?? "{}")),
13
52
  };
14
53
  }
15
- export async function callToolRaw(context, name, args) {
16
- const result = (await context.client.callTool({
54
+ export async function callToolJson(context, name, args) {
55
+ return callClientToolJson(context.client, name, args);
56
+ }
57
+ export async function callClientToolRaw(client, name, args) {
58
+ const result = (await client.callTool({
17
59
  name,
18
- arguments: args,
60
+ arguments: withDefaultTargetPage(name, args),
19
61
  }));
20
62
  expect(result.isError).not.toBe(true);
21
63
  return result;
22
64
  }
65
+ export async function callToolRaw(context, name, args) {
66
+ return callClientToolRaw(context.client, name, args);
67
+ }
@@ -0,0 +1,31 @@
1
+ import { stripSchemaRecursively } from "./strip-schema.js";
2
+ /**
3
+ * Creates a wrapped version of McpServer.tool that automatically strips
4
+ * `$schema` from the inputSchema after tool registration.
5
+ *
6
+ * The `$schema` key injected by zodToJsonSchema causes Claude Code to silently
7
+ * drop tools because the `$` character fails Anthropic's validation regex
8
+ * `^[a-zA-Z0-9_.-]{1,64}$`.
9
+ *
10
+ * @param server - The McpServer instance to wrap
11
+ * @returns The same server instance, but with a modified tool() method
12
+ */
13
+ export function createServerWithSchemaStripping(server) {
14
+ // Store original tool method
15
+ const originalTool = server.tool.bind(server);
16
+ // Override tool method
17
+ server.tool = function tool(name, description, inputSchema, handler) {
18
+ // Call original tool method
19
+ const result = originalTool(name, description, inputSchema, handler);
20
+ // Strip $schema from the registered tool's inputSchema
21
+ // This must be done after registration
22
+ setTimeout(() => {
23
+ const registeredTool = server._registeredTools?.[name];
24
+ if (registeredTool?.inputSchema) {
25
+ registeredTool.inputSchema = stripSchemaRecursively(registeredTool.inputSchema);
26
+ }
27
+ }, 0);
28
+ return result;
29
+ };
30
+ return server;
31
+ }
@@ -0,0 +1,29 @@
1
+ export function create_request_queue(log) {
2
+ const tails = new Map();
3
+ return {
4
+ enqueue(key, task) {
5
+ const run = async () => {
6
+ log.debug(`[queue] starting queued request on ${key}`);
7
+ try {
8
+ return await task();
9
+ }
10
+ finally {
11
+ log.debug(`[queue] finished queued request on ${key}`);
12
+ }
13
+ };
14
+ const tail = tails.get(key) ?? Promise.resolve();
15
+ const result = tail.then(run, run);
16
+ const nextTail = result.then(() => undefined, () => undefined);
17
+ tails.set(key, nextTail);
18
+ nextTail.finally(() => {
19
+ if (tails.get(key) === nextTail) {
20
+ tails.delete(key);
21
+ }
22
+ });
23
+ return result;
24
+ },
25
+ _active_tail_count() {
26
+ return tails.size;
27
+ },
28
+ };
29
+ }