diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52428f1..d8be1c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,16 @@ jobs: - name: Run unit tests run: npm run test -- --run + # The MCP server package ships its own vitest because its DOM polyfill + # (linkedom) needs `environment: node`, while the root vitest uses jsdom + # for the Next.js app. Install + run its tests separately so CI catches + # multi-page mxfile regressions. + - name: Install MCP server dependencies + run: npm --prefix packages/mcp-server ci + + - name: Run MCP server unit tests + run: npm --prefix packages/mcp-server test + e2e: name: E2E Tests runs-on: ubuntu-latest diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index bb18111..f01e40a 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", @@ -20,12 +20,47 @@ "devDependencies": { "@types/node": "^24.0.0", "tsx": "^4.19.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.8" }, "engines": { "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -480,6 +515,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -520,6 +562,342 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -530,6 +908,119 @@ "undici-types": "~7.16.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -576,6 +1067,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -659,6 +1160,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -681,6 +1192,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -826,6 +1344,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -940,6 +1468,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1000,6 +1535,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1030,6 +1575,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1113,6 +1668,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1461,6 +2034,267 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/linkedom": { "version": "0.18.12", "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", @@ -1485,6 +2319,16 @@ } } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1546,6 +2390,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1588,6 +2451,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1657,6 +2534,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -1666,6 +2570,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -1749,6 +2682,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1927,6 +2894,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1936,6 +2927,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1945,6 +2987,14 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2024,6 +3074,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2039,6 +3257,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 706b458..c5b179e 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.0", + "version": "0.2.1", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", @@ -11,6 +11,8 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", "prepublishOnly": "npm run build" }, "keywords": [ @@ -44,7 +46,8 @@ "devDependencies": { "@types/node": "^24.0.0", "tsx": "^4.19.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.8" }, "engines": { "node": ">=18" diff --git a/packages/mcp-server/src/diagram-operations.ts b/packages/mcp-server/src/diagram-operations.ts index 93225fc..4711de7 100644 --- a/packages/mcp-server/src/diagram-operations.ts +++ b/packages/mcp-server/src/diagram-operations.ts @@ -1,8 +1,14 @@ /** * ID-based diagram operations - * Copied from lib/utils.ts to avoid cross-package imports + * + * The xmlContent argument may be either a bare (legacy) or a + * full with one or more pages. For mxfile inputs, an + * optional pageSelector identifies which page to edit; when omitted, the + * first page is targeted (the "active page by convention" — see pages.ts). */ +import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js" + export interface DiagramOperation { operation: "update" | "add" | "delete" cell_id: string @@ -22,15 +28,18 @@ export interface ApplyOperationsResult { /** * Apply diagram operations (update/add/delete) using ID-based lookup. - * This replaces the text-matching approach with direct DOM manipulation. * - * @param xmlContent - The full mxfile XML content - * @param operations - Array of operations to apply - * @returns Object with result XML and any errors + * @param xmlContent - The diagram XML. May be either a bare or + * a full with one or more children. + * @param operations - Array of operations to apply. + * @param pageSelector - Optional page selector for multi-page docs. Defaults + * to the first page. + * @returns Object with result XML (same shape as input) and any per-op errors. */ export function applyDiagramOperations( xmlContent: string, operations: DiagramOperation[], + pageSelector?: PageSelector, ): ApplyOperationsResult { const errors: OperationError[] = [] @@ -53,22 +62,75 @@ export function applyDiagramOperations( } } - // Find the root element (inside mxGraphModel) - const root = doc.querySelector("root") - if (!root) { - return { - result: xmlContent, - errors: [ - { - type: "update", - cellId: "", - message: "Could not find element in XML", - }, - ], + // Locate the element to operate on. + // + // - For input: resolve the page via pageSelector, then dive into + // its . This scopes querySelectorAll calls below to one page so + // cells on other pages aren't accidentally matched. + // - For bare input: use the document's only . + let root: Element | null + if (doc.documentElement?.tagName === "mxfile") { + const found = findPageElement(doc as unknown as Document, pageSelector) + if (!found) { + const selDesc = hasPageSelector(pageSelector) + ? ` matching selector ${JSON.stringify(pageSelector)}` + : "" + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: `Page${selDesc} not found in `, + }, + ], + } + } + root = found.element.querySelector("root") + if (!root) { + const pageId = + found.element.getAttribute("id") || `(index ${found.index})` + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: `Page "${pageId}" has no element`, + }, + ], + } + } + } else { + if (hasPageSelector(pageSelector)) { + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: + "Page selector provided but document is not multi-page (no wrapper). Use create_new_diagram with a full first, or omit the page selector.", + }, + ], + } + } + root = doc.querySelector("root") + if (!root) { + return { + result: xmlContent, + errors: [ + { + type: "update", + cellId: "", + message: "Could not find element in XML", + }, + ], + } } } - // Build a map of cell IDs to elements + // Build a map of cell IDs to elements (scoped to the resolved page). const cellMap = new Map() root.querySelectorAll("mxCell").forEach((cell) => { const id = cell.getAttribute("id") @@ -208,7 +270,9 @@ export function applyDiagramOperations( cellsToDelete.add(cellId) // Find children (cells where parent === cellId) - const children = root.querySelectorAll( + // Scoped to `root` so other pages' cells with the same parent id + // (notably "1") are never touched. + const children = root!.querySelectorAll( `mxCell[parent="${cellId}"]`, ) children.forEach((child) => { diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index c75a48f..f23a245 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -93,6 +93,7 @@ interface SessionState { svg?: string // Cached SVG from last browser save syncRequested?: number // Timestamp when sync requested, cleared when browser responds exportFormat?: "png" | "svg" // Set by MCP tool to request browser export + exportXml?: string // Single-page projection to load before a page-targeted export exportData?: string // Base64/SVG data returned by browser after export } @@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number { svg: svg || existing?.svg, // Preserve cached SVG if not provided syncRequested: undefined, // Clear sync request when browser pushes state exportFormat: existing?.exportFormat, // Preserve pending export request + exportXml: existing?.exportXml, // Preserve pending projection exportData: existing?.exportData, // Preserve export result }) log.debug(`State updated: session=${sessionId}, version=${newVersion}`) return newVersion } +/** + * Ask the browser bridge to export the current diagram as png/svg. + * + * When `projectionXml` is given (a single-page ), the bridge loads it + * first, waits for draw.io's own load event, exports, then reloads the + * session's real document — so a page-targeted export never mutates the + * canonical session state and needs no fixed-delay guessing on the server. + * + * Returns false when the session is unknown. Callers should then poll + * `getState(sessionId)?.exportData` for the result. + */ +export function requestExport( + sessionId: string, + format: "png" | "svg", + projectionXml?: string, +): boolean { + const state = stateStore.get(sessionId) + if (!state) return false + state.exportData = undefined + state.exportXml = projectionXml + state.exportFormat = format + return true +} + export function requestSync(sessionId: string): boolean { const state = stateStore.get(sessionId) if (state) { @@ -286,6 +312,7 @@ function handleStateApi( version: state?.version || 0, syncRequested: !!state?.syncRequested, exportFormat: state?.exportFormat || null, + exportXml: state?.exportXml || null, }), ) } else if (req.method === "POST") { @@ -305,6 +332,7 @@ function handleStateApi( if (state) { state.exportData = data.exportData state.exportFormat = undefined + state.exportXml = undefined log.debug( `Export data received for session=${sessionId}`, ) @@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string { let pendingSvgExport = null; let pendingAiSvg = false; let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export + let projectionExportActive = false; // page-targeted export: showing a transient single-page projection + let projectionRestoreXml = null; // the real document to reload once a projection export finishes window.addEventListener('message', (e) => { if (e.origin !== '${DRAWIO_ORIGIN}') return; @@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string { isReady = true; if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { + // Ignore autosave while a single-page projection is on screen + // for a page-targeted export — otherwise we'd push the + // transient projection back as the canonical session state. + if (projectionExportActive) return; // Request SVG export, then push state with SVG pendingSvgExport = msg.xml; iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); @@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, exportData: d }) }).catch(() => {}); + // Page-targeted export: restore the user's real + // multi-page document now that we have the image. + restoreFromProjection(); return; } } @@ -761,6 +798,22 @@ function getHtmlPage(sessionId: string): string { } } + // Restore the user's real document after a page-targeted projection + // export. If we never captured one (lastXml was null at projection + // start), fall back to forcing a reload from the server on the next + // poll by rewinding currentVersion — never leave the iframe stuck on + // the transient projection. + function restoreFromProjection() { + if (!projectionExportActive) return; + projectionExportActive = false; + if (projectionRestoreXml) { + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*'); + projectionRestoreXml = null; + } else { + currentVersion = -1; // force the next poll to reload from server + } + } + async function pushState(xml, svg = '') { if (!sessionId) return; try { @@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string { pendingSyncExport = true; iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*'); } - // Load new diagram from server (before export, so we export latest) - if (s.version > currentVersion && s.xml) { + // Load new diagram from server (before export, so we export latest). + // While a page-targeted projection is on screen, skip the reload + // so it doesn't fight the projection — and leave currentVersion + // unadvanced so this bump is re-detected and applied once the + // real document is restored. + if (s.version > currentVersion && s.xml && !projectionExportActive) { currentVersion = s.version; loadDiagram(s.xml, true); } - // Handle export request from MCP server (png/svg) - after version update + // Handle export request from MCP server (png/svg). + // + // Plain export: capture whatever tab is currently displayed. + // + // Page-targeted export: the server sends a single-page + // projection in s.exportXml. We load it into the iframe, let + // draw.io render it, export, then reload the user's real + // document — all browser-side. The canonical session state is + // never mutated, so there is no server-side restore race and no + // dependence on poll timing. autosave is suppressed while the + // projection is showing (see projectionExportActive guard). if (s.exportFormat && !pendingMcpExport && isReady) { pendingMcpExport = s.exportFormat; - const exportOpts = s.exportFormat === 'png' - ? { action: 'export', format: 'png', scale: 2 } - : { action: 'export', format: 'svg' }; - iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); - // Timeout: reset if draw.io never responds - setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000); + const fireExport = () => { + const exportOpts = pendingMcpExport === 'png' + ? { action: 'export', format: 'png', scale: 2 } + : { action: 'export', format: 'svg' }; + iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); + }; + if (s.exportXml) { + // Stash the real document so we can restore after export. + projectionRestoreXml = lastXml; + projectionExportActive = true; + // Load the projection without touching lastXml/server state. + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*'); + // Let draw.io render the loaded page before exporting + // (same proven settle delay as the AI-preview path). + setTimeout(fireExport, 600); + } else { + fireExport(); + } + // Timeout: reset if draw.io never responds, and restore the + // real document if a projection was left showing. + setTimeout(() => { + if (pendingMcpExport) { + pendingMcpExport = null; + restoreFromProjection(); + } + }, 10000); } } catch {} } @@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string { saveConfirmBtn.textContent = 'Exporting...'; if (format === 'drawio') { - // Use lastXml directly instead of requesting export (avoids race with SVG exports) + // Use lastXml directly instead of requesting export (avoids race with SVG exports). + // session.xml is canonically after the multi-page refactor, + // so no wrapper injection is needed. The legacy fallback below + // remains only for documents that somehow slipped past + // normalisation (e.g. an older session loaded from external state). let xmlData = lastXml || ''; if (xmlData && !xmlData.includes(''; diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 563c4b8..314dc50 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,6 +6,16 @@ * draw.io diagrams with real-time browser preview. * * Uses an embedded HTTP server - no external dependencies required. + * + * Multi-page support + * ------------------ + * The canonical in-memory shape for the session XML is always an + * containing one or more pages. Legacy callers that pass a bare + * to create_new_diagram are auto-wrapped into a single-page + * mxfile. All page-targeting parameters (page_id / page_name / page_index) + * on edit_diagram, get_diagram, and export_diagram are optional and default + * to the first page. See packages/mcp-server/src/pages.ts for the helper + * surface. */ // Setup DOM polyfill for Node.js (required for XML operations) @@ -37,6 +47,7 @@ import { import { addHistory } from "./history.js" import { getState, + requestExport, requestSync, setState, shutdown, @@ -44,6 +55,18 @@ import { waitForSync, } from "./http-server.js" import { log } from "./logger.js" +import { + addPageToDoc, + deletePageFromDoc, + hasPageSelector, + listPagesFromDoc, + normalizeToMxfile, + type PageSelector, + parseMxfile, + projectPage, + renamePageInDoc, + serializeMxfile, +} from "./pages.js" import { validateAndFixXml } from "./xml-validation.js" // Server configuration @@ -62,9 +85,63 @@ let currentSession: { // Create MCP server const server = new McpServer({ name: "next-ai-drawio", - version: "0.1.2", + version: "0.3.0", }) +// Shared Zod schema fragment for page-targeting parameters. +// Every multi-page-aware tool reuses these three optional fields so the LLM +// learns one consistent interface. +const pageSelectorSchema = { + page_id: z + .string() + .min(1) + .optional() + .describe( + "Target a page by its id (as returned by list_pages or add_page). Wins over page_name and page_index when multiple are set.", + ), + page_name: z + .string() + .min(1) + .optional() + .describe( + 'Target a page by its display name (e.g. "CNN"). Used only when page_id is not set.', + ), + page_index: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Target a page by its 0-based tab index. Used only when page_id and page_name are not set.", + ), +} + +/** + * Pull a clean PageSelector out of a tool's parsed input. + * Returns an empty object when none of the page_* fields are set, so callers + * can simply pass it through to the lower layers (they treat empty as "first + * page" by convention). + */ +function pickPageSelector(input: { + page_id?: string + page_name?: string + page_index?: number +}): PageSelector { + const selector: PageSelector = {} + if (input.page_id) selector.page_id = input.page_id + if (input.page_name) selector.page_name = input.page_name + if (input.page_index !== undefined) selector.page_index = input.page_index + return selector +} + +/** Format a selector for human-readable error messages. */ +function describeSelector(s: PageSelector): string { + if (s.page_id) return `id="${s.page_id}"` + if (s.page_name) return `name="${s.page_name}"` + if (s.page_index !== undefined) return `index=${s.page_index}` + return "first page" +} + // Register prompt with workflow guidance server.prompt( "diagram-workflow", @@ -79,22 +156,28 @@ server.prompt( ## Creating a New Diagram 1. Call start_session to open the browser preview -2. Use create_new_diagram with complete mxGraphModel XML to create a new diagram +2. Use create_new_diagram with either a bare (single page) or a full with one or more children (multi-page) -## Adding Elements to Existing Diagram -1. Use edit_diagram with "add" operation +## Working with Multiple Pages +- Use list_pages to discover existing pages (id, name, index) +- Use add_page to append a new page (without losing existing ones — unlike create_new_diagram which REPLACES everything) +- Use rename_page / delete_page for management +- edit_diagram, get_diagram, and export_diagram all accept optional page_id / page_name / page_index — when omitted they target the first page + +## Adding Elements to an Existing Page +1. Use edit_diagram with "add" operation, optionally with a page selector 2. Provide a unique cell_id and complete mxCell XML 3. No need to call get_diagram first - the server fetches latest state automatically ## Modifying or Deleting Existing Elements -1. FIRST call get_diagram to see current cell IDs and structure +1. FIRST call get_diagram to see current cell IDs and page structure 2. THEN call edit_diagram with "update" or "delete" operations 3. For update, provide the cell_id and complete new mxCell XML ## Important Notes -- create_new_diagram REPLACES the entire diagram - only use for new diagrams -- edit_diagram PRESERVES user's manual changes (fetches browser state first) -- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`, +- create_new_diagram REPLACES the entire document, including ALL pages - only use for new diagrams. Use add_page to add a tab without losing existing content. +- edit_diagram PRESERVES the user's manual changes (fetches browser state first) +- Always use unique cell_ids within a page (cell ids "0" and "1" are reserved root sentinels and can repeat across pages)`, }, }, ], @@ -155,24 +238,25 @@ server.registerTool( server.registerTool( "create_new_diagram", { - description: `Create a NEW diagram from mxGraphModel XML. ONLY use this when creating a diagram from scratch. + description: `Create a NEW diagram from XML. ONLY use this when creating a diagram from scratch. -⚠️ DO NOT use this tool to modify an existing diagram — it will DESTROY all existing content and user changes. Use edit_diagram instead for ANY modifications to an existing diagram. +⚠️ DESTRUCTIVE: This tool REPLACES the entire document, INCLUDING every existing page/tab and any unsaved user changes. To add a tab without losing existing content, use add_page instead. To modify cells on an existing page, use edit_diagram. CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml. When to use this tool: -- Creating a new diagram from scratch (no existing diagram) +- Creating a new diagram from scratch (no existing diagram, or wanting to wipe and start over) - The user explicitly asks to "start over" or "create a new diagram" -When to use edit_diagram instead (ALWAYS prefer edit_diagram if a diagram already exists): -- ANY modifications to an existing diagram -- Adding/removing/moving elements -- Changing labels, colors, styles, or positions -- Restructuring or reorganizing existing content -- Adding new elements to an existing diagram +When to use add_page instead: +- The user wants ANOTHER tab/page alongside what's already there (e.g. "add a CNN diagram on a new page") -XML FORMAT - Full mxGraphModel structure: +When to use edit_diagram instead: +- ANY modifications to an existing page's cells (add/remove/move shapes, change labels, etc.) + +ACCEPTED XML SHAPES: + +1) Bare mxGraphModel (single-page, legacy): @@ -182,11 +266,23 @@ XML FORMAT - Full mxGraphModel structure: +The server auto-wraps this in .... -LAYOUT CONSTRAINTS: +2) Full mxfile (one or more pages): + + + ... + + + ... + + +Each becomes a tab in the embedded editor. Cell ids "0" and "1" are reserved root sentinels and MUST repeat in every page's . + +LAYOUT CONSTRAINTS (per page): - Keep all elements within x=0-800, y=0-600 (single page viewport) - Start from margins (x=40, y=40), keep elements grouped closely -- Use unique IDs starting from "2" (0 and 1 are reserved) +- Use unique IDs starting from "2" within each page (0 and 1 are reserved) - Set parent="1" for top-level shapes - Space shapes 150-200px apart for clear edge routing @@ -205,7 +301,7 @@ COMMON STYLES: xml: z .string() .describe( - "REQUIRED: The complete mxGraphModel XML. Must always be provided.", + "REQUIRED: Either a complete (legacy single-page) or a full with one or more children (multi-page).", ), }, }, @@ -223,7 +319,7 @@ COMMON STYLES: } } - // Validate and auto-fix XML + // Validate and auto-fix XML (works for both mxfile and mxGraphModel inputs). let xml = inputXml const { valid, error, fixed, fixes } = validateAndFixXml(xml) if (fixed) { @@ -243,6 +339,23 @@ COMMON STYLES: } } + // Normalise to the canonical mxfile shape so every later tool can + // assume "session.xml is always an mxfile". Bare + // inputs are wrapped into a single-page mxfile here. + const normalized = normalizeToMxfile(xml) + if (!normalized) { + return { + content: [ + { + type: "text", + text: "Error: XML must be either a or an with one or more children.", + }, + ], + isError: true, + } + } + xml = normalized + log.info(`Setting diagram content, ${xml.length} chars`) // Sync from browser state first @@ -271,13 +384,24 @@ COMMON STYLES: // Save AI result (no SVG yet - will be captured by browser) addHistory(currentSession.id, xml, "") - log.info(`Diagram content set successfully`) + // Report page count back to the caller so the LLM learns whether + // multi-page worked or fell back to single. + const doc = parseMxfile(xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageSummary = + pages.length > 1 + ? `${pages.length} pages: ${pages.map((p) => `${p.index}:${p.name}`).join(", ")}` + : pages.length === 1 + ? `1 page: ${pages[0].name}` + : "no pages parsed" + + log.info(`Diagram content set successfully (${pageSummary})`) return { content: [ { type: "text", - text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`, + text: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`, }, ], } @@ -298,26 +422,32 @@ server.registerTool( "edit_diagram", { description: - "Edit the current diagram by ID-based operations (update/add/delete cells).\n\n" + + "Edit a specific page in the current diagram by ID-based operations (update/add/delete cells).\n\n" + "⚠️ REQUIRED: You MUST call get_diagram BEFORE this tool!\n" + "This fetches the latest state from the browser including any manual user edits.\n" + "Skipping get_diagram WILL cause user's changes to be LOST.\n\n" + "Workflow:\n" + - "1. Call get_diagram to see current cell IDs and structure\n" + + "1. Call get_diagram to see current cell IDs, page structure, and active page\n" + "2. Use the returned XML to construct your edit operations\n" + - "3. Call edit_diagram with your operations\n\n" + + "3. Call edit_diagram with your operations and (optionally) a page selector\n\n" + + "Multi-page targeting:\n" + + "- page_id / page_name / page_index are optional; when all omitted, the FIRST page is targeted\n" + + "- Use list_pages to discover what pages exist\n\n" + "Operations:\n" + - "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + + "- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + "For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" + - "Example - Add a rectangle:\n" + + "Example - Add a rectangle on the default (first) page:\n" + '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": ""}]}\n\n' + - "Example - Update a cell:\n" + - '{"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]}\n\n' + - "Example - Delete a cell:\n" + + "Example - Add a cell on a specific page by name:\n" + + '{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": ""}]}\n\n' + + "Example - Update a cell on page index 1:\n" + + '{"page_index": 1, "operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]}\n\n' + + "Example - Delete a cell on the default page:\n" + '{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}', inputSchema: { + ...pageSelectorSchema, operations: z .array( z.object({ @@ -338,7 +468,7 @@ server.registerTool( .describe("Array of operations to apply"), }, }, - async ({ operations }) => { + async ({ operations, page_id, page_name, page_index }) => { try { if (!currentSession) { return { @@ -373,10 +503,14 @@ server.registerTool( } } - // Fetch latest state from browser + // Fetch latest state from browser. Re-normalise to mxfile: the + // embed/sync path can hand back a bare , and adopting + // it verbatim would silently strip a multi-page document down to + // one page on the next write. const browserState = getState(currentSession.id) if (browserState?.xml) { - currentSession.xml = browserState.xml + currentSession.xml = + normalizeToMxfile(browserState.xml) ?? browserState.xml log.info("Fetched latest diagram state from browser") } @@ -392,13 +526,13 @@ server.registerTool( } } - log.info(`Editing diagram with ${operations.length} operation(s)`) - - // Save before editing (with cached SVG from browser) - addHistory( - currentSession.id, - currentSession.xml, - browserState?.svg || "", + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + log.info( + `Editing diagram with ${operations.length} operation(s) on ${describeSelector(pageSelector)}`, ) // Validate and auto-fix new_xml for each operation @@ -422,10 +556,11 @@ server.registerTool( return op }) - // Apply operations + // Apply operations on the targeted page const { result, errors } = applyDiagramOperations( currentSession.xml, validatedOps as DiagramOperation[], + pageSelector, ) if (errors.length > 0) { @@ -435,6 +570,33 @@ server.registerTool( log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`) } + // A page-level error (empty cellId — e.g. the selector matched no + // page, or the page had no ) means NOTHING was applied and + // `result` is the unchanged input. Surface it as a hard error + // instead of persisting a no-op and reporting success, so the + // caller doesn't build on a wrong assumption. + const pageError = errors.find((e) => e.cellId === "") + if (pageError) { + return { + content: [ + { + type: "text", + text: `Error: ${pageError.message}`, + }, + ], + isError: true, + } + } + + // Save the pre-edit state for undo (with cached SVG from browser). + // Done only now that we know the edit applied — a page-level error + // returns above without leaving a phantom history entry. + addHistory( + currentSession.id, + currentSession.xml, + browserState?.svg || "", + ) + // Update state currentSession.xml = result currentSession.version++ @@ -447,7 +609,7 @@ server.registerTool( log.info(`Diagram edited successfully`) - const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).` + const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s) on ${describeSelector(pageSelector)}.` const errorMsg = errors.length > 0 ? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}` @@ -480,9 +642,19 @@ server.registerTool( description: "Get the current diagram XML (fetches latest from browser, including user's manual edits). " + "Call this BEFORE edit_diagram if you need to update or delete existing elements, " + - "so you can see the current cell IDs and structure.", + "so you can see the current cell IDs, pages, and structure.\n\n" + + "Returns the full by default. If a page selector is provided, returns just that page's embedded in a one-page wrapper.", + inputSchema: { + ...pageSelectorSchema, + }, }, - async () => { + async (input) => { + // Defensive: when every field is optional an MCP client could in + // principle invoke us with no `arguments` field. The SDK's zod parse + // normally produces `{}` in that case, but we coalesce explicitly so + // a destructure of `undefined` can never throw before we reach the + // session-existence check. + const { page_id, page_name, page_index } = input ?? {} try { if (!currentSession) { return { @@ -508,10 +680,13 @@ server.registerTool( // Mark that get_diagram was called (for edit_diagram workflow check) currentSession.lastGetDiagramTime = Date.now() - // Fetch latest state from browser + // Fetch latest state from browser, re-normalising to mxfile so a + // bare pushed back by the embed/sync path doesn't + // strip page structure (see edit_diagram for the same guard). const browserState = getState(currentSession.id) if (browserState?.xml) { - currentSession.xml = browserState.xml + currentSession.xml = + normalizeToMxfile(browserState.xml) ?? browserState.xml } if (!currentSession.xml) { @@ -525,11 +700,50 @@ server.registerTool( } } + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + const doc = parseMxfile(currentSession.xml) + const pages = doc ? listPagesFromDoc(doc) : [] + const pageList = pages.length + ? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}` + : "No wrapper detected (legacy single-page session)." + + // No selector → return full mxfile + if (!hasPageSelector(pageSelector)) { + return { + content: [ + { + type: "text", + text: `Current diagram XML:\n\n${currentSession.xml}\n\n${pageList}`, + }, + ], + } + } + + // Selector → return a single-page projection + const projection = projectPage(currentSession.xml, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? `Error: a page selector was given but the current session XML could not be parsed as a multi-page (it may be a legacy single-page document or malformed), so it has no addressable pages.\n\n${pageList}` + : `Error: Page ${describeSelector(pageSelector)} not found.\n\n${pageList}`, + }, + ], + isError: true, + } + } return { content: [ { type: "text", - text: `Current diagram XML:\n\n${currentSession.xml}`, + text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}`, }, ], } @@ -551,8 +765,14 @@ server.registerTool( { description: "Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " + - "The format is auto-detected from the file extension, or can be specified explicitly.", + "The format is auto-detected from the file extension, or can be specified explicitly.\n\n" + + "Multi-page behaviour:\n" + + "- .drawio with NO page selector: writes the full (all pages).\n" + + "- .drawio with a page selector: writes a single-page containing only that page.\n" + + "- .png / .svg with NO page selector: exports the currently active page in the browser.\n" + + "- .png / .svg with a page selector: temporarily loads a single-page projection of that page into the browser, captures the rendered image, then restores the full document. The user will see a brief tab-flicker (~1-2s) but the exported image is guaranteed to be the requested page.", inputSchema: { + ...pageSelectorSchema, path: z .string() .describe( @@ -566,7 +786,7 @@ server.registerTool( ), }, }, - async ({ path, format }) => { + async ({ path, format, page_id, page_name, page_index }) => { try { if (!currentSession) { return { @@ -598,6 +818,12 @@ server.registerTool( } } + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + const fs = await import("node:fs/promises") const nodePath = await import("node:path") @@ -607,20 +833,44 @@ server.registerTool( format || (ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio") - // Original .drawio export path (unchanged logic) + // .drawio path - write XML directly (no browser round-trip). if (detectedFormat === "drawio") { let filePath = path if (!filePath.endsWith(".drawio")) { filePath = `${filePath}.drawio` } const absolutePath = nodePath.resolve(filePath) - await fs.writeFile(absolutePath, currentSession.xml, "utf-8") + + let outXml = currentSession.xml + if (hasPageSelector(pageSelector)) { + const projection = projectPage( + currentSession.xml, + pageSelector, + ) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? "Error: Cannot parse current session XML as ; cannot project a single page." + : `Error: Page ${describeSelector(pageSelector)} not found for export.`, + }, + ], + isError: true, + } + } + outXml = projection.xml + } + + await fs.writeFile(absolutePath, outXml, "utf-8") log.info(`Diagram exported to ${absolutePath}`) return { content: [ { type: "text", - text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, + text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${outXml.length} characters`, }, ], } @@ -648,26 +898,74 @@ server.registerTool( isError: true, } } - state.exportFormat = detectedFormat as "png" | "svg" - state.exportData = undefined - // Wait for browser to produce the export data - const timeoutMs = 10000 + // ----------------------------------------------------------------- + // Page-targeted PNG/SVG export. + // + // drawio's JSON embed protocol has no working `selectPage` action, + // so to export a specific page we build a single-page + // projection and hand it to the browser bridge alongside the export + // request. The bridge loads the projection, waits for draw.io's own + // render, exports, then reloads the user's real document — entirely + // browser-side. The canonical session state is never mutated here, + // so there is no restore race and no concurrent-edit clobbering. + // ----------------------------------------------------------------- + let projectionXml: string | undefined + if (hasPageSelector(pageSelector)) { + const projection = projectPage(currentSession.xml, pageSelector) + if (!projection.ok) { + return { + content: [ + { + type: "text", + text: + projection.reason === "parse" + ? "Error: Cannot parse current session XML as ; cannot target page for export." + : `Error: Page ${describeSelector(pageSelector)} not found for export.`, + }, + ], + isError: true, + } + } + projectionXml = projection.xml + } + + // Ask the browser to export (optionally via a page projection) and + // poll for the resulting image data. + requestExport( + currentSession.id, + detectedFormat as "png" | "svg", + projectionXml, + ) + + // A projection export does an extra load + render round-trip in the + // browser, so give it a longer window. Re-read the live store entry + // each tick: setState() (from a concurrent autosave or tool call) + // replaces the Map entry with a new object, so a captured reference + // would go stale and never observe the browser's exportData. + const timeoutMs = projectionXml ? 15000 : 10000 const start = Date.now() + let exportData: string | undefined while (Date.now() - start < timeoutMs) { - if (state.exportData) break + exportData = getState(currentSession.id)?.exportData + if (exportData) break await new Promise((r) => setTimeout(r, 200)) } - const exportData = state.exportData as string | undefined - state.exportData = undefined - state.exportFormat = undefined + const live = getState(currentSession.id) + if (live) { + live.exportData = undefined + live.exportFormat = undefined + live.exportXml = undefined + } if (!exportData) { return { content: [ { type: "text", - text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.", + text: projectionXml + ? "Error: Export timed out after loading the single-page projection. The browser may be closed or unresponsive." + : "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.", }, ], isError: true, @@ -717,6 +1015,392 @@ server.registerTool( }, ) +/** + * Shared helper for page-CRUD tools. + * Loads the latest session XML, normalises to mxfile if needed, returns a + * parsed Document the caller can mutate, plus a writer that persists. + */ +async function loadMxfileForMutation(): Promise< + | { ok: true; doc: Document; writeBack: (newDoc: Document) => void } + | { ok: false; message: string } +> { + if (!currentSession) { + return { + ok: false, + message: "No active session. Please call start_session first.", + } + } + // Pull latest from browser so we don't clobber autosaved changes. + const browserState = getState(currentSession.id) + if (browserState?.xml) { + currentSession.xml = browserState.xml + } + if (!currentSession.xml) { + return { + ok: false, + message: + "No diagram exists yet. Use create_new_diagram first, then page tools.", + } + } + // Make sure the in-memory shape is canonical mxfile before any CRUD. + const normalized = normalizeToMxfile(currentSession.xml) + if (!normalized) { + return { + ok: false, + message: + "Current session XML is neither nor ; cannot perform page operations.", + } + } + currentSession.xml = normalized + + const doc = parseMxfile(currentSession.xml) + if (!doc) { + return { + ok: false, + message: "Failed to parse current session XML as .", + } + } + const sessionRef = currentSession + return { + ok: true, + doc, + writeBack: (newDoc: Document) => { + const newXml = serializeMxfile(newDoc) + // Save history before overwriting so the user can undo. + addHistory(sessionRef.id, sessionRef.xml, browserState?.svg || "") + sessionRef.xml = newXml + sessionRef.version++ + // Page CRUD updates the structure that get_diagram would return, + // so refresh the workflow timestamp — subsequent edit_diagram + // calls don't need a redundant get_diagram round-trip. + sessionRef.lastGetDiagramTime = Date.now() + setState(sessionRef.id, newXml) + addHistory(sessionRef.id, newXml, "") + }, + } +} + +// Tool: list_pages +server.registerTool( + "list_pages", + { + description: + "List every page (tab) in the current diagram. Returns each page's id, name, 0-based index, and cell count. Use this to discover what pages exist before targeting one with edit_diagram, get_diagram, export_diagram, rename_page, or delete_page.", + inputSchema: {}, + }, + async () => { + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + const pages = listPagesFromDoc(loaded.doc) + if (pages.length === 0) { + return { + content: [ + { + type: "text", + text: "No pages in document.", + }, + ], + } + } + const lines = pages.map( + (p) => + ` [${p.index}] id="${p.id}" name="${p.name}" cells=${p.cellCount}`, + ) + return { + content: [ + { + type: "text", + text: `Pages (${pages.length}):\n${lines.join("\n")}`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("list_pages failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: add_page +server.registerTool( + "add_page", + { + description: + 'Append a new page (tab) to the current diagram WITHOUT touching existing pages or unsaved user changes. Use this when the user wants "another diagram alongside" — e.g. "add a CNN page" — instead of create_new_diagram which wipes everything.\n\n' + + "Inputs:\n" + + "- name: optional display name for the tab (defaults to Page-N where N = existing-page-count + 1)\n" + + "- id: optional explicit page id; if omitted the server generates a short alphanumeric id\n" + + '- xml: optional starting for the new page. If omitted, the page starts blank with the standard root sentinel cells ("0" and "1").\n\n' + + "Returns the new page's id, name, and index so the caller can immediately target it with edit_diagram.", + inputSchema: { + name: z + .string() + .optional() + .describe( + 'Optional display name for the new tab (e.g. "CNN"). Defaults to "Page-N".', + ), + id: z + .string() + .min(1) + .optional() + .describe( + "Optional explicit page id. If omitted the server generates one. Must be unique across pages.", + ), + xml: z + .string() + .optional() + .describe( + 'Optional starting XML for the new page. Must include with id="0" and id="1" cells. If omitted the page starts blank.', + ), + }, + }, + async (input) => { + // All three fields optional — coalesce so a no-args call doesn't + // crash on destructure before we surface a proper MCP error. + const { name, id, xml } = input ?? {} + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + // If caller provided XML, validate it before splicing it in so we + // never get a half-broken mxfile written to the session. + let cleanXml: string | undefined = xml + if (cleanXml) { + const { valid, error, fixed, fixes } = + validateAndFixXml(cleanXml) + if (fixed) { + cleanXml = fixed + log.info( + `add_page: starting XML auto-fixed: ${fixes.join(", ")}`, + ) + } + if (!valid && error) { + return { + content: [ + { + type: "text", + text: `Error: starting xml validation failed - ${error}`, + }, + ], + isError: true, + } + } + } + + let info + try { + info = addPageToDoc(loaded.doc, { id, name, xml: cleanXml }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { + content: [{ type: "text", text: `Error: ${msg}` }], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Added page id=${info.id} name="${info.name}" index=${info.index}`, + ) + return { + content: [ + { + type: "text", + text: `Page added.\n\nid=${info.id}\nname=${info.name}\nindex=${info.index}\ncells=${info.cellCount}`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("add_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: rename_page +server.registerTool( + "rename_page", + { + description: + "Rename an existing page (tab). At least one of page_id / page_name / page_index is required to identify which page to rename. The new_name becomes the visible tab label in the editor.", + inputSchema: { + ...pageSelectorSchema, + new_name: z + .string() + .min(1) + .describe("The new display name for the page tab."), + }, + }, + async ({ new_name, page_id, page_name, page_index }) => { + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + if (!hasPageSelector(pageSelector)) { + return { + content: [ + { + type: "text", + text: "Error: rename_page requires one of page_id, page_name, or page_index to identify the page.", + }, + ], + isError: true, + } + } + + const ok = renamePageInDoc(loaded.doc, pageSelector, new_name) + if (!ok) { + return { + content: [ + { + type: "text", + text: `Error: Page ${describeSelector(pageSelector)} not found.`, + }, + ], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Renamed page ${describeSelector(pageSelector)} → "${new_name}"`, + ) + return { + content: [ + { + type: "text", + text: `Page ${describeSelector(pageSelector)} renamed to "${new_name}".`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("rename_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + +// Tool: delete_page +server.registerTool( + "delete_page", + { + description: + "Delete a page (tab) from the current diagram. At least one of page_id / page_name / page_index is required. Refuses to delete the last remaining page — the editor needs at least one tab.", + inputSchema: { + ...pageSelectorSchema, + }, + }, + async (input) => { + // All three fields are optional — coalesce so a no-args call returns + // a clean error message instead of crashing on destructure. + const { page_id, page_name, page_index } = input ?? {} + try { + const loaded = await loadMxfileForMutation() + if (!loaded.ok) { + return { + content: [ + { type: "text", text: `Error: ${loaded.message}` }, + ], + isError: true, + } + } + + const pageSelector = pickPageSelector({ + page_id, + page_name, + page_index, + }) + if (!hasPageSelector(pageSelector)) { + return { + content: [ + { + type: "text", + text: "Error: delete_page requires one of page_id, page_name, or page_index to identify the page.", + }, + ], + isError: true, + } + } + + const outcome = deletePageFromDoc(loaded.doc, pageSelector) + if (!outcome.ok) { + return { + content: [ + { + type: "text", + text: `Error: ${outcome.reason}.`, + }, + ], + isError: true, + } + } + + loaded.writeBack(loaded.doc) + log.info( + `Deleted page id=${outcome.deletedId} index=${outcome.deletedIndex}`, + ) + return { + content: [ + { + type: "text", + text: `Page deleted (id=${outcome.deletedId}, was at index ${outcome.deletedIndex}).`, + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + log.error("delete_page failed:", message) + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + } + } + }, +) + // Graceful shutdown handler let isShuttingDown = false function gracefulShutdown(reason: string) { diff --git a/packages/mcp-server/src/pages.ts b/packages/mcp-server/src/pages.ts new file mode 100644 index 0000000..26f273d --- /dev/null +++ b/packages/mcp-server/src/pages.ts @@ -0,0 +1,316 @@ +/** + * Multi-page (mxfile) helpers for draw.io diagrams. + * + * The on-disk and embed-protocol shape of a draw.io document is: + * + * + * + * ... + * + * ...one or more children... + * + * + * This module centralises page CRUD so that index.ts, xml-validation.ts, + * and diagram-operations.ts can all agree on: + * - what "the canonical in-memory shape" is (always mxfile), + * - how to find a page (id, name, or index), + * - how to add/rename/delete pages without re-parsing ad-hoc. + */ + +import { DOMParser } from "linkedom" + +export interface PageInfo { + id: string + name: string + index: number + cellCount: number +} + +/** Selector used by all multi-page-aware tools. All fields optional. */ +export interface PageSelector { + page_id?: string + page_name?: string + page_index?: number +} + +/** True if the selector targets a specific page (any field set). */ +export function hasPageSelector(s?: PageSelector | null): boolean { + if (!s) return false + return ( + Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined + ) +} + +/** + * Generate a short page id similar in shape to drawio's auto-assigned ids. + * Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself + * uses short ids; collisions are still astronomically unlikely for one session. + */ +export function generatePageId(): string { + const a = Math.random().toString(36).substring(2, 10) + const b = Math.random().toString(36).substring(2, 6) + return `${a}-${b}` +} + +/** Cheap regex check — does the XML start with an root? */ +export function isMxFile(xml: string): boolean { + return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) +} + +/** Cheap regex check — does the XML start with a bare ? */ +export function isMxGraphModel(xml: string): boolean { + return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) +} + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} + +/** + * Strip a leading declaration from an XML string. The XML spec + * only permits the declaration at the very start of a document, so embedding + * a declaration inside another element produces invalid XML. Callers must + * strip before splicing a fragment into a wrapper. + */ +function stripXmlDeclaration(xml: string): string { + return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "") +} + +/** + * Wrap a bare XML string in .... + * If the input is already an mxfile, returns it unchanged. + * If the input is neither shape, returns null so the caller can surface a clear error. + * + * Strips any leading declaration before embedding — a declaration is + * only valid at the very start of a document, never inside a . + */ +export function normalizeToMxfile( + xml: string, + opts: { pageId?: string; pageName?: string; host?: string } = {}, +): string | null { + const trimmed = xml.trim() + if (!trimmed) return null + if (isMxFile(trimmed)) return trimmed + if (!isMxGraphModel(trimmed)) return null + + const pageId = opts.pageId || generatePageId() + const pageName = opts.pageName || "Page-1" + const host = opts.host || "app.diagrams.net" + const inner = stripXmlDeclaration(trimmed) + return `${inner}` +} + +/** + * Parse an mxfile XML string. Returns null on parse error or if the root + * isn't — callers are expected to have run normalizeToMxfile first. + */ +export function parseMxfile(xml: string): Document | null { + try { + const doc = new DOMParser().parseFromString(xml, "text/xml") + if (doc.querySelector("parsererror")) return null + if (doc.documentElement?.tagName !== "mxfile") return null + return doc as unknown as Document + } catch { + return null + } +} + +/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */ +export function serializeMxfile(doc: Document): string { + const serializer = new XMLSerializer() + return serializer.serializeToString(doc) +} + +export type PageProjection = + | { ok: true; xml: string; index: number; name: string } + | { ok: false; reason: "parse" | "notfound" } + +/** + * Project a single page out of an mxfile string into a standalone one-page + * . Used by get_diagram and export_diagram so the three call sites + * share one parse → find → serialise path. + * + * Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile, + * or { ok:false, reason:"notfound" } if the selector matches no page. + */ +export function projectPage( + xml: string, + selector: PageSelector, +): PageProjection { + const doc = parseMxfile(xml) + if (!doc) return { ok: false, reason: "parse" } + const found = findPageElement(doc, selector) + if (!found) return { ok: false, reason: "notfound" } + const serializer = new XMLSerializer() + return { + ok: true, + xml: `${serializer.serializeToString(found.element)}`, + index: found.index, + name: found.element.getAttribute("name") || "", + } +} + +/** Walk every child of and return summary info. */ +export function listPagesFromDoc(doc: Document): PageInfo[] { + const diagrams = doc.querySelectorAll("diagram") + const result: PageInfo[] = [] + diagrams.forEach((d, idx) => { + const root = d.querySelector("root") + const cellCount = root ? root.querySelectorAll("mxCell").length : 0 + result.push({ + id: d.getAttribute("id") || "", + name: d.getAttribute("name") || `Page-${idx + 1}`, + index: idx, + cellCount, + }) + }) + return result +} + +/** + * Resolve a page selector to its element. + * Resolution order: page_id → page_name → page_index → default (first page). + * + * When no selector field is set we return the first page — the "active page + * by convention" mentioned in §3.4 of the design doc. + */ +export function findPageElement( + doc: Document, + selector?: PageSelector, +): { element: Element; index: number } | null { + const diagrams = Array.from(doc.querySelectorAll("diagram")) + if (diagrams.length === 0) return null + + if (!hasPageSelector(selector)) { + return { element: diagrams[0], index: 0 } + } + + if (selector?.page_id) { + for (let i = 0; i < diagrams.length; i++) { + if (diagrams[i].getAttribute("id") === selector.page_id) { + return { element: diagrams[i], index: i } + } + } + return null + } + if (selector?.page_name) { + for (let i = 0; i < diagrams.length; i++) { + if (diagrams[i].getAttribute("name") === selector.page_name) { + return { element: diagrams[i], index: i } + } + } + return null + } + if (selector && selector.page_index !== undefined) { + const idx = selector.page_index + if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) { + return { element: diagrams[idx], index: idx } + } + return null + } + + return null +} + +/** + * Append a new to the mxfile doc. The new page's model defaults to + * an empty . + * + * `opts.xml` must be a BARE — passing a full would + * end up nested inside , which is malformed. We reject the mxfile + * shape explicitly and strip any declaration (only valid at + * document start, never inside ). + * + * Returns the new PageInfo. Throws if the requested id collides or the xml + * shape is wrong. + */ +export function addPageToDoc( + doc: Document, + opts: { id?: string; name?: string; xml?: string } = {}, +): PageInfo { + const existing = listPagesFromDoc(doc) + const id = opts.id || generatePageId() + if (existing.some((p) => p.id === id)) { + throw new Error(`Page id "${id}" already exists`) + } + const name = opts.name || `Page-${existing.length + 1}` + + let inner: string + if (opts.xml?.trim()) { + const trimmed = stripXmlDeclaration(opts.xml.trim()) + if (isMxFile(trimmed)) { + throw new Error( + "addPageToDoc: opts.xml must be a bare ; received a full . Extract the target diagram's first.", + ) + } + if (!isMxGraphModel(trimmed)) { + throw new Error( + "addPageToDoc: opts.xml must be a bare .", + ) + } + inner = trimmed + } else { + inner = `` + } + + const snippet = `${inner}` + const tempDoc = new DOMParser().parseFromString(snippet, "text/xml") + if (tempDoc.querySelector("parsererror")) { + throw new Error( + "Failed to parse new page xml — make sure it is a valid ", + ) + } + const newDiagram = tempDoc.querySelector("diagram") + if (!newDiagram) { + throw new Error("Failed to construct element for new page") + } + + const imported = doc.importNode(newDiagram, true) as Element + doc.documentElement.appendChild(imported) + + return { + id, + name, + index: existing.length, + cellCount: imported.querySelectorAll("mxCell").length, + } +} + +/** Rename the page matched by selector. Returns true on success. */ +export function renamePageInDoc( + doc: Document, + selector: PageSelector, + newName: string, +): boolean { + const found = findPageElement(doc, selector) + if (!found) return false + found.element.setAttribute("name", newName) + return true +} + +/** + * Delete a page. Refuses to delete the last remaining page — the embed needs + * at least one diagram to render anything, and silently recreating one would + * be surprising behaviour for an MCP caller. + */ +export function deletePageFromDoc( + doc: Document, + selector: PageSelector, +): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } { + const pages = listPagesFromDoc(doc) + if (pages.length <= 1) { + return { ok: false, reason: "Cannot delete the only remaining page" } + } + const found = findPageElement(doc, selector) + if (!found) { + return { ok: false, reason: "Page not found" } + } + const id = found.element.getAttribute("id") || "" + const index = found.index + found.element.parentNode?.removeChild(found.element) + return { ok: true, deletedId: id, deletedIndex: index } +} diff --git a/packages/mcp-server/src/xml-validation.ts b/packages/mcp-server/src/xml-validation.ts index 278978d..8577d62 100644 --- a/packages/mcp-server/src/xml-validation.ts +++ b/packages/mcp-server/src/xml-validation.ts @@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null { return null } -/** Check for duplicate IDs in XML */ +/** + * Check for duplicate IDs in XML. + * + * For multi-page documents ( with multiple children), cell + * IDs are unique **within a page**, not across the whole document — drawio + * legitimately reuses "0" and "1" for the root cells of every page. So we + * scope the cell-ID uniqueness check per , and additionally check + * that the ids themselves are unique. + * + * The legacy regex-based check is kept as a fallback for non-mxfile inputs + * and for XML that won't DOM-parse. + */ function checkDuplicateIds(xml: string): string | null { + // The DOM-aware path only matters for wrappers; for legacy + // bare inputs (the overwhelming majority of historic + // traffic), the cheap regex fallback at the bottom is enough. A quick + // string check avoids paying the DOMParser cost on every call. + const mightBeMxFile = /]/i.test(xml) + + // Try DOM-aware, page-scoped check first when the input looks mxfile-ish. + if (mightBeMxFile) + try { + const doc = new DOMParser().parseFromString(xml, "text/xml") + if (!doc.querySelector("parsererror")) { + const rootEl = doc.documentElement + if (rootEl && rootEl.tagName === "mxfile") { + const diagrams = doc.querySelectorAll("diagram") + + // 1) ids must be unique across the file. + const diagramIds = new Map() + diagrams.forEach((d) => { + const id = d.getAttribute("id") + if (id) + diagramIds.set(id, (diagramIds.get(id) || 0) + 1) + }) + const dupDiagrams = Array.from(diagramIds.entries()) + .filter(([, c]) => c > 1) + .map(([id]) => `'${id}'`) + if (dupDiagrams.length > 0) { + return `Invalid XML: Found duplicate id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.` + } + + // 2) Within each page, mxCell ids must be unique. + for (let i = 0; i < diagrams.length; i++) { + const diagram = diagrams[i] + const pageId = + diagram.getAttribute("id") || `(index ${i})` + const cells = diagram.querySelectorAll("mxCell") + const cellIds = new Map() + cells.forEach((c) => { + const id = c.getAttribute("id") + if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1) + }) + const dups = Array.from(cellIds.entries()) + .filter(([, c]) => c > 1) + .map(([id, count]) => `'${id}' (${count}x)`) + if (dups.length > 0) { + return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.` + } + } + return null + } + } + } catch { + // fall through to regex + } + + // Legacy regex-based check for bare and parse-error cases. const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi const ids = new Map() let idMatch @@ -770,35 +836,46 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) } - // 22. Fix duplicate IDs by appending suffix - const seenIds = new Map() - const duplicateIds: string[] = [] + // 22. Fix duplicate IDs by appending suffix. + // Skipped for multi-page documents — cell ids "0" and "1" repeat + // across pages legitimately (every page has its own with id="0"/"1" + // sentinel cells). Renaming them would break drawio's parent references. + // For mxfile inputs, duplicate-id validation is page-scoped in + // checkDuplicateIds() and a true duplicate produces a hard error rather + // than a silent rename. + if (!/]/i.test(fixed)) { + const seenIds = new Map() + const duplicateIds: string[] = [] - const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi - let idMatch - while ((idMatch = idPattern.exec(fixed)) !== null) { - const id = idMatch[1] - seenIds.set(id, (seenIds.get(id) || 0) + 1) - } + const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi + let idMatch + while ((idMatch = idPattern.exec(fixed)) !== null) { + const id = idMatch[1] + seenIds.set(id, (seenIds.get(id) || 0) + 1) + } - for (const [id, count] of seenIds) { - if (count > 1) duplicateIds.push(id) - } + for (const [id, count] of seenIds) { + if (count > 1) duplicateIds.push(id) + } - if (duplicateIds.length > 0) { - const idCounters = new Map() - fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => { - if (!duplicateIds.includes(id)) return match + if (duplicateIds.length > 0) { + const idCounters = new Map() + fixed = fixed.replace( + /\bid\s*=\s*["']([^"']+)["']/gi, + (match, id) => { + if (!duplicateIds.includes(id)) return match - const count = idCounters.get(id) || 0 - idCounters.set(id, count + 1) + const count = idCounters.get(id) || 0 + idCounters.set(id, count + 1) - if (count === 0) return match + if (count === 0) return match - const newId = `${id}_dup${count}` - return match.replace(id, newId) - }) - fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) + const newId = `${id}_dup${count}` + return match.replace(id, newId) + }, + ) + fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) + } } // 23. Fix empty id attributes diff --git a/packages/mcp-server/tests/multi-page.test.ts b/packages/mcp-server/tests/multi-page.test.ts new file mode 100644 index 0000000..bdcb5c6 --- /dev/null +++ b/packages/mcp-server/tests/multi-page.test.ts @@ -0,0 +1,545 @@ +/** + * Unit tests for multi-page (mxfile) support. + * + * Pinned to the user-visible contract described in + * multi-page-mcp-support-plan.md §5 (acceptance criteria): + * + * AC1. create_new_diagram accepts both bare and full . + * AC2. get_diagram returns the full regardless of page count. + * AC3. edit_diagram accepts an optional page selector. + * AC6. Two tool calls reproduce the Transformer/CNN scenario. + * AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary. + * + * These tests pin the helpers (pages.ts), the validator update + * (xml-validation.ts), and the page-targeted edit logic + * (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface. + */ + +import { DOMParser } from "linkedom" +import { beforeAll, describe, expect, it } from "vitest" + +// Install the DOM polyfill exactly as index.ts does at runtime — the +// helpers under test rely on it. +beforeAll(() => { + ;(globalThis as any).DOMParser = DOMParser + class XMLSerializerPolyfill { + serializeToString(node: any): string { + if (node.outerHTML !== undefined) return node.outerHTML + if (node.documentElement) return node.documentElement.outerHTML + return "" + } + } + ;(globalThis as any).XMLSerializer = XMLSerializerPolyfill +}) + +import { applyDiagramOperations } from "../src/diagram-operations.js" +import { + addPageToDoc, + deletePageFromDoc, + findPageElement, + generatePageId, + hasPageSelector, + isMxFile, + isMxGraphModel, + listPagesFromDoc, + normalizeToMxfile, + parseMxfile, + projectPage, + renamePageInDoc, + serializeMxfile, +} from "../src/pages.js" +import { validateAndFixXml } from "../src/xml-validation.js" + +const BARE_MODEL_ONE_CELL = `` + +const TWO_PAGE_MXFILE = `` + +describe("pages.ts — shape detection", () => { + it("isMxFile detects a multi-page mxfile", () => { + expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true) + }) + + it("isMxFile rejects a bare mxGraphModel", () => { + expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false) + }) + + it("isMxGraphModel detects a bare model", () => { + expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true) + expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false) + }) + + it("isMxFile tolerates an XML declaration prefix", () => { + expect( + isMxFile( + `${TWO_PAGE_MXFILE}`, + ), + ).toBe(true) + }) +}) + +describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => { + it("wraps a bare mxGraphModel into a single-page mxfile", () => { + const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, { + pageId: "p1", + pageName: "Page-1", + }) + expect(out).not.toBeNull() + expect(out).toMatch(/^`) + expect(out).toContain("") + }) + + it("returns mxfile inputs unchanged", () => { + const out = normalizeToMxfile(TWO_PAGE_MXFILE) + expect(out).toBe(TWO_PAGE_MXFILE) + }) + + it("returns null for neither shape", () => { + expect(normalizeToMxfile("")).toBeNull() + expect(normalizeToMxfile("")).toBeNull() + }) + + it("generated page ids look reasonable", () => { + for (let i = 0; i < 50; i++) { + const id = generatePageId() + expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/) + } + }) + + it("strips a leading declaration when wrapping a bare model", () => { + // Regression for the bug Copilot caught: isMxGraphModel tolerates a + // declaration prefix, but the wrapper used to embed it inside + // , producing invalid XML ( is only valid at the + // document start). The result must round-trip through parseMxfile + // and the declaration must be gone from inside . + const withDecl = `${BARE_MODEL_ONE_CELL}` + const out = normalizeToMxfile(withDecl, { + pageId: "p1", + pageName: "Page-1", + }) + expect(out).not.toBeNull() + expect(out).toMatch(/^ { + it("rejects opts.xml shaped as a full ", () => { + // Regression for the Copilot-flagged bug: an mxfile passed as + // starting page xml would end up nested inside , corrupting + // the document. Must throw with a clear message. + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(() => + addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }), + ).toThrowError(/bare /i) + }) + + it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(() => + addPageToDoc(doc, { name: "Junk", xml: "" }), + ).toThrowError(/bare /i) + }) + + it("strips a declaration prefix on opts.xml", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const withDecl = `${BARE_MODEL_ONE_CELL}` + const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl }) + expect(info.cellCount).toBeGreaterThanOrEqual(3) + // Serialised document must not have inside . + const out = serializeMxfile(doc) + // The mxfile may have one at the very start (the doc decl), + // but no further occurrence inside . + const matches = out.match(/<\?xml/g) || [] + expect(matches.length).toBeLessThanOrEqual(1) + }) +}) + +describe("pages.ts — listPagesFromDoc / findPageElement", () => { + it("lists both pages in a two-page mxfile", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const pages = listPagesFromDoc(doc) + expect(pages).toHaveLength(2) + expect(pages[0]).toMatchObject({ + id: "page-transformer", + name: "Transformer", + index: 0, + }) + expect(pages[1]).toMatchObject({ + id: "page-cnn", + name: "CNN", + index: 1, + }) + // Cell count is per-page (3 cells per page including the two root sentinels). + expect(pages[0].cellCount).toBe(3) + expect(pages[1].cellCount).toBe(3) + }) + + it("findPageElement defaults to the first page when selector is empty", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const found = findPageElement(doc) + expect(found?.index).toBe(0) + expect(found?.element.getAttribute("id")).toBe("page-transformer") + }) + + it("findPageElement matches by id, name, and index — id wins when several are set", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1) + expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1) + expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1) + // id beats name beats index + const winner = findPageElement(doc, { + page_id: "page-cnn", + page_name: "Transformer", + page_index: 0, + }) + expect(winner?.index).toBe(1) + }) + + it("findPageElement returns null for an unknown selector", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(findPageElement(doc, { page_id: "ghost" })).toBeNull() + expect(findPageElement(doc, { page_name: "ghost" })).toBeNull() + expect(findPageElement(doc, { page_index: 99 })).toBeNull() + expect(findPageElement(doc, { page_index: -1 })).toBeNull() + }) + + it("hasPageSelector correctly detects empty vs populated selectors", () => { + expect(hasPageSelector()).toBe(false) + expect(hasPageSelector({})).toBe(false) + expect(hasPageSelector({ page_id: "x" })).toBe(true) + expect(hasPageSelector({ page_index: 0 })).toBe(true) + }) +}) + +describe("pages.ts — addPageToDoc", () => { + it("appends a third page and returns its info", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const info = addPageToDoc(doc, { name: "Sequence" }) + expect(info.name).toBe("Sequence") + expect(info.index).toBe(2) + expect(info.id).toMatch(/.+/) + const pages = listPagesFromDoc(doc) + expect(pages).toHaveLength(3) + expect(pages[2].name).toBe("Sequence") + }) + + it("rejects a duplicate explicit id", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(() => + addPageToDoc(doc, { id: "page-transformer", name: "X" }), + ).toThrowError(/already exists/) + }) + + it("uses a sensible default name when none is supplied", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const info = addPageToDoc(doc, {}) + expect(info.name).toBe("Page-3") + }) + + it("accepts an inline starting mxGraphModel", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const inner = `` + const info = addPageToDoc(doc, { name: "Custom", xml: inner }) + expect(info.cellCount).toBeGreaterThanOrEqual(3) + }) +}) + +describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => { + it("renames an existing page by name", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2") + expect(ok).toBe(true) + const pages = listPagesFromDoc(doc) + expect(pages[1].name).toBe("CNN-v2") + }) + + it("rename returns false when target page is missing", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false) + }) + + it("deletes a page and removes the element from the doc", () => { + const doc = parseMxfile(TWO_PAGE_MXFILE)! + const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" }) + expect(outcome.ok).toBe(true) + expect(outcome.deletedId).toBe("page-cnn") + expect(listPagesFromDoc(doc)).toHaveLength(1) + }) + + it("refuses to delete the only remaining page", () => { + // Build a single-page doc to test the guard. + const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)! + const doc = parseMxfile(single)! + const outcome = deletePageFromDoc(doc, { page_index: 0 }) + expect(outcome.ok).toBe(false) + expect(outcome.reason).toMatch(/only remaining page/) + }) +}) + +describe("xml-validation.ts — multi-page support", () => { + it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => { + const result = validateAndFixXml(TWO_PAGE_MXFILE) + expect(result.valid).toBe(true) + expect(result.error).toBeNull() + }) + + it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => { + // This is the regression the planning doc explicitly called out: + // before this work, the legacy regex-based duplicate-id check rejected + // any multi-page document because cells "0" and "1" appear in every page. + const result = validateAndFixXml(TWO_PAGE_MXFILE) + expect(result.valid).toBe(true) + }) + + it("rejects duplicate cell ids WITHIN a single page", () => { + const bad = `` + const result = validateAndFixXml(bad) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/duplicate cell ID/i) + }) + + it("rejects duplicate ids across the file", () => { + const bad = `` + const result = validateAndFixXml(bad) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/duplicate id/i) + }) + + it("still validates a bare (legacy callers)", () => { + const result = validateAndFixXml(BARE_MODEL_ONE_CELL) + expect(result.valid).toBe(true) + }) + + it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => { + // Build a doc that triggers some other auto-fix (so autoFixXml runs) + // but contains valid multi-page 0/1 cells that must NOT be renamed. + const malformedButMultiPage = `` + const result = validateAndFixXml(malformedButMultiPage) + // The doc has an unescaped & — autoFix will repair that. After repair + // it should be valid AND must not have renamed the 0/1 cells. + const finalXml = result.fixed || malformedButMultiPage + expect(finalXml).not.toMatch(/id="0_dup/) + expect(finalXml).not.toMatch(/id="1_dup/) + }) +}) + +describe("diagram-operations.ts — page-targeted edits (AC3)", () => { + it("adds a cell to the targeted page by id, leaving the other page untouched", () => { + const { result, errors } = applyDiagramOperations( + TWO_PAGE_MXFILE, + [ + { + operation: "add", + cell_id: "conv-2", + new_xml: ``, + }, + ], + { page_id: "page-cnn" }, + ) + expect(errors).toHaveLength(0) + const doc = parseMxfile(result)! + const pages = listPagesFromDoc(doc) + // Transformer untouched (still 3 cells), CNN gained one cell. + expect(pages[0].cellCount).toBe(3) + expect(pages[1].cellCount).toBe(4) + expect(result).toContain(`id="conv-2"`) + }) + + it("defaults to the first page when no selector is given", () => { + const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [ + { + operation: "add", + cell_id: "shape-x", + new_xml: ``, + }, + ]) + expect(errors).toHaveLength(0) + const doc = parseMxfile(result)! + const pages = listPagesFromDoc(doc) + expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew + expect(pages[1].cellCount).toBe(3) // CNN untouched + }) + + it("errors clearly when the page is not found", () => { + const { errors } = applyDiagramOperations( + TWO_PAGE_MXFILE, + [ + { + operation: "delete", + cell_id: "2", + }, + ], + { page_id: "does-not-exist" }, + ) + expect(errors).toHaveLength(1) + expect(errors[0].message).toMatch(/Page.*not found/i) + // Page-level errors carry an empty cellId — edit_diagram relies on + // this to distinguish "nothing applied" from per-cell warnings and + // return a hard error instead of a false success. + expect(errors[0].cellId).toBe("") + }) + + it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => { + // Both pages have a cell with id="2". A delete on CNN's "2" must not + // remove Transformer's "2". + const { result, errors } = applyDiagramOperations( + TWO_PAGE_MXFILE, + [{ operation: "delete", cell_id: "2" }], + { page_id: "page-cnn" }, + ) + expect(errors).toHaveLength(0) + const doc = parseMxfile(result)! + const pages = listPagesFromDoc(doc) + // CNN lost its only non-sentinel cell, Transformer keeps its three. + expect(pages[1].cellCount).toBe(2) + expect(pages[0].cellCount).toBe(3) + }) + + it("legacy bare-mxGraphModel input still works when no selector is given", () => { + const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [ + { + operation: "add", + cell_id: "new", + new_xml: ``, + }, + ]) + expect(errors).toHaveLength(0) + expect(result).toContain(`id="new"`) + }) + + it("page selector on a bare mxGraphModel returns a clear error", () => { + const { errors } = applyDiagramOperations( + BARE_MODEL_ONE_CELL, + [{ operation: "delete", cell_id: "2" }], + { page_id: "page-1" }, + ) + expect(errors).toHaveLength(1) + expect(errors[0].message).toMatch(/not multi-page/i) + }) +}) + +describe("export_diagram — single-page projection (regression for selectPage bug)", () => { + // The previous implementation tried to drive drawio's iframe with an + // `action: 'selectPage'` postMessage, which the embed protocol silently + // ignores. The result was that PNG/SVG exports targeted the currently + // active tab regardless of the page selector — two visually different + // pages would yield byte-identical PNGs. + // + // The current implementation builds a single-page projection via + // the shared pages.ts:projectPage helper and hands it to the browser + // bridge to load BEFORE triggering export. These tests pin that helper so + // a future refactor can't silently re-introduce the multi-page drift. + function projectSinglePage(fullMxfile: string, sel: any): string { + const result = projectPage(fullMxfile, sel) + if (!result.ok) throw new Error(`projection failed: ${result.reason}`) + return result.xml + } + + it("returns a parse error for a non-mxfile source", () => { + const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("parse") + }) + + it("returns a notfound error for an unknown page", () => { + const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("notfound") + }) + + it("projects only the requested page when targeted by id", () => { + const projected = projectSinglePage(TWO_PAGE_MXFILE, { + page_id: "page-cnn", + }) + const pages = listPagesFromDoc(parseMxfile(projected)!) + expect(pages).toHaveLength(1) + expect(pages[0].id).toBe("page-cnn") + expect(pages[0].name).toBe("CNN") + // The projection must NOT contain the Transformer page anywhere. + expect(projected).not.toContain('id="page-transformer"') + expect(projected).not.toContain('name="Transformer"') + }) + + it("projects only the requested page when targeted by name", () => { + const projected = projectSinglePage(TWO_PAGE_MXFILE, { + page_name: "Transformer", + }) + const pages = listPagesFromDoc(parseMxfile(projected)!) + expect(pages).toHaveLength(1) + expect(pages[0].name).toBe("Transformer") + expect(projected).not.toContain('id="page-cnn"') + }) + + it("projects only the requested page when targeted by index", () => { + const projected = projectSinglePage(TWO_PAGE_MXFILE, { + page_index: 1, + }) + const pages = listPagesFromDoc(parseMxfile(projected)!) + expect(pages).toHaveLength(1) + expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection + expect(pages[0].id).toBe("page-cnn") + }) + + it("two different page selectors produce visually distinct projections", () => { + // The regression: under the old selectPage bug, two exports would + // return the same active tab. With the projection approach, the + // payload that drawio renders is provably different. + const a = projectSinglePage(TWO_PAGE_MXFILE, { + page_id: "page-transformer", + }) + const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" }) + expect(a).not.toBe(b) + expect(a).toContain('"Encoder"') + expect(a).not.toContain('"Conv1"') + expect(b).toContain('"Conv1"') + expect(b).not.toContain('"Encoder"') + }) + + it("the projection parses to a valid one-page mxfile", () => { + const projected = projectSinglePage(TWO_PAGE_MXFILE, { + page_id: "page-cnn", + }) + // Validator accepts it. + expect(validateAndFixXml(projected).valid).toBe(true) + // And it has a real with the cells from the source page. + const doc = parseMxfile(projected)! + const root = doc.querySelector("root") + expect(root).not.toBeNull() + const conv1 = doc.querySelector('mxCell[value="Conv1"]') + expect(conv1).not.toBeNull() + }) +}) + +describe("end-to-end — Transformer + CNN scenario (AC6)", () => { + it("two tool-equivalent steps reproduce the motivating user scenario", () => { + // Step 1 — caller passes a single-page mxfile. + const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, { + pageId: "page-transformer", + pageName: "Transformer", + }) + expect(step1).not.toBeNull() + let xml = step1 as string + const validate1 = validateAndFixXml(xml) + expect(validate1.valid).toBe(true) + + // Step 2 — equivalent of add_page("CNN") with a starting model. + const doc = parseMxfile(xml)! + addPageToDoc(doc, { + id: "page-cnn", + name: "CNN", + xml: ``, + }) + xml = serializeMxfile(doc) + + // Now: two pages, both valid, with the right names. + const pages = listPagesFromDoc(parseMxfile(xml)!) + expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"]) + expect(validateAndFixXml(xml).valid).toBe(true) + }) +}) diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts new file mode 100644 index 0000000..29daa36 --- /dev/null +++ b/packages/mcp-server/tests/server-wiring.test.ts @@ -0,0 +1,141 @@ +/** + * Server-wiring test: boot the actual MCP stdio server (from source via tsx) + * and drive it the way a real MCP client does — initialize handshake, + * tools/list — to catch registration/schema regressions that the unit tests + * (which import helpers directly) can't see. + * + * This replaces the old standalone tests/smoke.mjs, which spawned the BUILT + * dist/index.js and was therefore never run in CI (CI doesn't build this + * package before testing). Running from source via tsx means it executes as + * part of the normal `vitest run`. + * + * We deliberately do NOT call start_session — it would open a real browser + * window via open(). The browser bridge is covered by the Playwright e2e suite. + */ + +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const entry = path.resolve(__dirname, "..", "src", "index.ts") +const tsxBin = path.resolve( + __dirname, + "..", + "node_modules", + ".bin", + process.platform === "win32" ? "tsx.cmd" : "tsx", +) + +const EXPECTED_TOOLS = [ + "start_session", + "create_new_diagram", + "edit_diagram", + "get_diagram", + "export_diagram", + "list_pages", + "add_page", + "rename_page", + "delete_page", +] + +let proc: ChildProcessWithoutNullStreams +let stdoutBuf = "" +const pending = new Map< + number, + { resolve: (m: any) => void; reject: (e: Error) => void; timeout: any } +>() +let nextId = 1 + +function send(method: string, params: unknown, isNotification = false) { + const msg: Record = { jsonrpc: "2.0", method, params } + if (!isNotification) msg.id = nextId++ + proc.stdin.write(`${JSON.stringify(msg)}\n`) + if (isNotification) return Promise.resolve(undefined) + return new Promise((resolve, reject) => { + const id = msg.id as number + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for response to ${method}`)) + }, 15000) + pending.set(id, { resolve, reject, timeout }) + }) +} + +beforeAll(async () => { + proc = spawn(tsxBin, [entry], { + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams + + proc.stdout.on("data", (chunk: Buffer) => { + stdoutBuf += chunk.toString() + const lines = stdoutBuf.split("\n") + stdoutBuf = lines.pop() || "" + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + let msg: any + try { + msg = JSON.parse(trimmed) + } catch { + // Non-JSON-RPC log line — ignore. + continue + } + const p = msg.id !== undefined ? pending.get(msg.id) : undefined + if (p) { + clearTimeout(p.timeout) + pending.delete(msg.id) + p.resolve(msg) + } + } + }) + + const initResp = await send("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "wiring-test", version: "0.0.0" }, + }) + expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined() + expect(initResp.result?.serverInfo?.name).toBeTruthy() + await send("notifications/initialized", {}, true) +}, 30000) + +afterAll(() => { + proc?.kill("SIGTERM") +}) + +describe("MCP server wiring", () => { + it("registers all nine multi-page tools", async () => { + const resp = await send("tools/list", {}) + expect(resp.error, JSON.stringify(resp.error)).toBeUndefined() + const names: string[] = (resp.result?.tools ?? []).map( + (t: { name: string }) => t.name, + ) + for (const expected of EXPECTED_TOOLS) { + expect(names, `missing tool: ${expected}`).toContain(expected) + } + }) + + it("advertises page-selector params on edit_diagram", async () => { + const resp = await send("tools/list", {}) + const edit = resp.result.tools.find( + (t: { name: string }) => t.name === "edit_diagram", + ) + const props = edit?.inputSchema?.properties ?? {} + expect(props.page_id).toBeTruthy() + expect(props.page_name).toBeTruthy() + expect(props.page_index).toBeTruthy() + }) + + it("advertises name/id/xml on add_page", async () => { + const resp = await send("tools/list", {}) + const addPage = resp.result.tools.find( + (t: { name: string }) => t.name === "add_page", + ) + const props = addPage?.inputSchema?.properties ?? {} + expect(props.name).toBeTruthy() + expect(props.id).toBeTruthy() + expect(props.xml).toBeTruthy() + }) +}) diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts new file mode 100644 index 0000000..3d3c44b --- /dev/null +++ b/packages/mcp-server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + // The package source uses Node16 module resolution with explicit .js + // extensions in imports. Vitest+esbuild handles the .ts→.js mapping + // transparently, so no extra alias config is needed. + }, +})