drawio-mcp-server 2.0.4 → 2.1.1

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 +30 -4
  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 +83 -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 +13 -5
@@ -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,83 @@
1
+ import { stripSchemaRecursively } from "./strip-schema.js";
2
+ /**
3
+ * Wraps an `McpServer` so that the `tools/list` response strips the
4
+ * `$schema` key from every tool's `inputSchema` and `outputSchema`.
5
+ *
6
+ * Why this is necessary:
7
+ *
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.
32
+ */
33
+ export function createServerWithSchemaStripping(server) {
34
+ const originalTool = server.tool.bind(server);
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
+ }
45
+ return result;
46
+ };
47
+ return server;
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
+ }