diff --git a/package-lock.json b/package-lock.json index 969725fc..718ec997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -210,16 +210,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.978.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", - "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "version": "3.980.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.980.1.tgz", + "integrity": "sha512-W7jDWUPucezIUjFZpr8MudQQtz/m6yPMyAFrOyyQcHjuF4UrZdcCdP8eV1js06U9sH3dxp0AbhN+s9uHwQyXzQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.6", - "@aws-sdk/xml-builder": "^3.972.41", + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.42", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.35.0", - "@smithy/signature-v4": "^5.7.3", + "@smithy/core": "^3.38.0", + "@smithy/signature-v4": "^5.7.5", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -394,17 +394,17 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", - "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "version": "3.998.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.998.1.tgz", + "integrity": "sha512-KPw5W6LN3waUskfDCRXRcyBEHAev5PVlMOjMq74OIivFYbgPvq39QeFege7IOFPncO425/AQK0qxdfaoeGejYQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.978.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.47", - "@aws-sdk/types": "^3.974.6", - "@smithy/core": "^3.35.0", + "@aws-sdk/core": "^3.980.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", "@smithy/fetch-http-handler": "^5.8.0", - "@smithy/node-http-handler": "^4.12.1", + "@smithy/node-http-handler": "^4.14.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -413,13 +413,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", - "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "version": "3.996.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.51.tgz", + "integrity": "sha512-+aHvUugVBuMPXxgkPfWnTMyGdE/yWS8GHi5+qQ37HowS0YqzrbuaDLznj+Dtmtu5jgCdqz36gXmujOToUhNNWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.6", - "@smithy/signature-v4": "^5.7.3", + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.5", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -445,9 +445,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", - "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.19.0", @@ -458,9 +458,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", - "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.42.tgz", + "integrity": "sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.19.0", @@ -2952,6 +2952,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz", + "integrity": "sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -5096,6 +5108,55 @@ "@testing-library/dom": "9.3.1" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz", + "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@mongodb-js/aggregation-stage-icons": { "resolved": "packages/aggregation-stage-icons", "link": true @@ -5204,6 +5265,10 @@ "resolved": "packages/devtools-connect", "link": true }, + "node_modules/@mongodb-js/devtools-mcp-server": { + "resolved": "packages/devtools-mcp-server", + "link": true + }, "node_modules/@mongodb-js/devtools-proxy-support": { "resolved": "packages/devtools-proxy-support", "link": true @@ -8844,12 +8909,12 @@ "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@smithy/core": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", - "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.39.0.tgz", + "integrity": "sha512-JJ/46AWQ4ipS1N2eZmUj7yzIhH8NhQIfAE+dce7NSB6dmFh+7hvD4pbofcoT4I0Xzfy/LNhZx3V2QhwYyAiqdg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.19.0", + "@smithy/types": "^4.20.0", "tslib": "^2.6.2" }, "engines": { @@ -8885,13 +8950,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", - "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.14.2.tgz", + "integrity": "sha512-i1uORV1v3qBqP43ayRKzIdm1q5x14tf8N4Dd06/QUX1Szm6VxHMrTsu20X5E63UFsaNZCRjI4SXG0jDmqYa+3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.33.3", - "@smithy/types": "^4.18.0", + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", "tslib": "^2.6.2" }, "engines": { @@ -8899,12 +8964,12 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", - "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "version": "5.7.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.5.tgz", + "integrity": "sha512-WPjdr5oWOk1LArpI4pavu+fSo+/evi0Re1olxGvqwuZopd4gYR1UGCi+Soyn/IjmafqbWs+ZC+3AKxJS3ZpwUg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.35.0", + "@smithy/core": "^3.38.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, @@ -8913,9 +8978,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", - "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.20.0.tgz", + "integrity": "sha512-lCA6QG22dPk9ecHbv91jDQZsmtPUjdS1aE2Il6NNPqe7DrYc0dZQi6DhcESk2KYXH2sp5xbAcGdYMZDFnuzA2A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -10930,7 +10995,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -10944,7 +11008,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10954,7 +11017,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -11044,7 +11106,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -11697,19 +11758,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -11865,7 +11925,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -12645,7 +12704,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12792,7 +12850,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12802,7 +12859,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -12830,6 +12886,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -13559,7 +13632,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13835,7 +13907,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/ejs": { @@ -13911,7 +13982,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -14294,7 +14364,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -15264,7 +15333,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15306,6 +15374,27 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz", @@ -15396,7 +15485,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -15436,11 +15524,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.3.tgz", + "integrity": "sha512-zPLqDI2f0gV2WmGnacLf9XSSqn4VldRyP5LCzOwx0BKH40mO6+2PCFkoSsRO2jePx87/Kf6NUF6P2QOTKXHbQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15450,7 +15556,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15460,7 +15565,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -15717,7 +15821,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -15999,7 +16102,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -16009,7 +16111,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17111,6 +17212,15 @@ "node": ">=0.10.0" } }, + "node_modules/hono": { + "version": "4.13.17", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz", + "integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", @@ -17246,7 +17356,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -17332,7 +17441,6 @@ "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -18799,7 +18907,6 @@ "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -18940,6 +19047,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -21595,7 +21708,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -21622,7 +21734,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -24295,7 +24406,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -24907,7 +25017,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -25048,6 +25157,15 @@ "node": ">=0.10.0" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -25493,7 +25611,6 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -25511,7 +25628,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -25585,10 +25701,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "dev": true, + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -25649,7 +25764,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -25663,7 +25777,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -26491,7 +26604,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -26508,14 +26620,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, "license": "MIT" }, "node_modules/router/node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -26757,7 +26867,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -26784,7 +26893,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -26794,7 +26902,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -26821,7 +26928,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -26894,7 +27000,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/shallow-clone": { @@ -27564,7 +27669,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -28235,7 +28339,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -28582,7 +28685,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -28601,7 +28703,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -28611,7 +28712,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -28970,7 +29070,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -29165,7 +29264,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -29940,12 +30038,20 @@ "version": "4.6.7", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", @@ -30325,6 +30431,83 @@ "mongodb-log-writer": "^3.0.6" } }, + "packages/devtools-mcp-server": { + "name": "@mongodb-js/devtools-mcp-server", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.32.2", + "@mongodb-js/mongodb-runner": "^8.4.9", + "mongodb": "~7.5.0", + "zod": "^4.6.7" + }, + "bin": { + "devtools-mcp-server": "dist/bin.js" + }, + "devDependencies": { + "@mongodb-js/eslint-config-devtools": "^0.11.15", + "@mongodb-js/mocha-config-devtools": "^3.0.0", + "@mongodb-js/prettier-config-devtools": "^1.0.6", + "@mongodb-js/tsconfig-devtools": "^1.1.5", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.1.1", + "@types/node": "^26.6.6", + "chai": "^4.5.0", + "depcheck": "^1.4.7", + "eslint": "^7.25.0 || ^8.0.0", + "gen-esm-wrapper": "^1.1.3", + "mocha": "^12.0.3", + "nyc": "^18.0.0", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + } + }, + "packages/devtools-mcp-server/node_modules/mongodb": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", + "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "packages/devtools-proxy-support": { "name": "@mongodb-js/devtools-proxy-support", "version": "0.7.32", diff --git a/package.json b/package.json index 93c137ec..c6955a76 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "smol-toml@<1.9.0": "^1.9.0", "tar@<7.5.21": "^7.5.22", "uuid@<11.1.1": "^11.1.1", - "yargs-parser@<=5.0.0": "^5.0.1" + "yargs-parser@<=5.0.0": "^5.0.1", + "body-parser@<2.4.0": "^2.4.0" } } diff --git a/packages/devtools-mcp-server/.depcheckrc b/packages/devtools-mcp-server/.depcheckrc new file mode 100644 index 00000000..4bd16867 --- /dev/null +++ b/packages/devtools-mcp-server/.depcheckrc @@ -0,0 +1,7 @@ +ignores: + - '@mongodb-js/prettier-config-devtools' + - '@mongodb-js/tsconfig-devtools' + - '@types/chai' + - '@types/node' +ignore-patterns: + - 'dist' diff --git a/packages/devtools-mcp-server/.eslintignore b/packages/devtools-mcp-server/.eslintignore new file mode 100644 index 00000000..85a8a75e --- /dev/null +++ b/packages/devtools-mcp-server/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/devtools-mcp-server/.eslintrc.js b/packages/devtools-mcp-server/.eslintrc.js new file mode 100644 index 00000000..8b94c21f --- /dev/null +++ b/packages/devtools-mcp-server/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-devtools'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, + overrides: [ + { + // Test doubles implement async interfaces without awaiting anything. + files: ['**/*.spec.ts'], + rules: { + '@typescript-eslint/require-await': 'off', + }, + }, + ], +}; diff --git a/packages/devtools-mcp-server/.gitignore b/packages/devtools-mcp-server/.gitignore new file mode 100644 index 00000000..b322b09d --- /dev/null +++ b/packages/devtools-mcp-server/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules/ +.dist/ +.build/ +npm-debug.log +coverage/ diff --git a/packages/devtools-mcp-server/.mocharc.js b/packages/devtools-mcp-server/.mocharc.js new file mode 100644 index 00000000..64afeb1f --- /dev/null +++ b/packages/devtools-mcp-server/.mocharc.js @@ -0,0 +1 @@ +module.exports = require('@mongodb-js/mocha-config-devtools'); diff --git a/packages/devtools-mcp-server/.npmignore b/packages/devtools-mcp-server/.npmignore new file mode 100644 index 00000000..f942db39 --- /dev/null +++ b/packages/devtools-mcp-server/.npmignore @@ -0,0 +1,13 @@ +package-lock.json +dist/ +.nyc_output +tmp-*/ +test/ +coverage/ +.eslintrc +.jsfmtrc +.travis.yml +.DS_Store +.dist/ +.build/ +npm-debug.log diff --git a/packages/devtools-mcp-server/.prettierignore b/packages/devtools-mcp-server/.prettierignore new file mode 100644 index 00000000..4d28df66 --- /dev/null +++ b/packages/devtools-mcp-server/.prettierignore @@ -0,0 +1,3 @@ +.nyc_output +dist +coverage diff --git a/packages/devtools-mcp-server/.prettierrc.json b/packages/devtools-mcp-server/.prettierrc.json new file mode 100644 index 00000000..dfae21d0 --- /dev/null +++ b/packages/devtools-mcp-server/.prettierrc.json @@ -0,0 +1 @@ +"@mongodb-js/prettier-config-devtools" diff --git a/packages/devtools-mcp-server/LICENSE b/packages/devtools-mcp-server/LICENSE new file mode 100644 index 00000000..5e0fd33c --- /dev/null +++ b/packages/devtools-mcp-server/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "{}" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright {yyyy} {name of copyright owner} + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/devtools-mcp-server/README.md b/packages/devtools-mcp-server/README.md new file mode 100644 index 00000000..996ad894 --- /dev/null +++ b/packages/devtools-mcp-server/README.md @@ -0,0 +1,236 @@ +# @mongodb-js/devtools-mcp-server + +A [Model Context Protocol](https://modelcontextprotocol.io) server for MongoDB +DevTools, and an OpenCode plugin that installs it together with the official +MongoDB MCP server. + +It wraps [`@mongodb-js/mongodb-runner`](../mongodb-runner) so an agent can start +real MongoDB deployments, seed and save fixtures, inspect logs, and tear +everything down — while the database operations themselves (find, insert, +aggregate, indexes, schema, ...) come from the official +[`mongodb-mcp-server`](https://www.mongodb.com/docs/mcp-server/). + +## Tools + +### Cluster tools + +| Tool | Description | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `mongodb_start_cluster` | Starts a deployment and returns its id and connection string. | +| `mongodb_list_clusters` | Lists the clusters tracked in the runner registry (no connection is made). | +| `mongodb_get_cluster` | Probes a cluster: reachable or not, servers, version, variant and log directory. | +| `mongodb_cluster_logs` | Returns the last lines of every mongod/mongos log of a cluster. | +| `mongodb_prune_stale_clusters` | Removes registry entries for clusters that are no longer reachable. | +| `mongodb_stop_cluster` | Stops one cluster by id, or every cluster with `all: true`. | + +Cluster metadata is stored in `~/.mongodb/runner2` (override with +`MONGODB_RUNNER_RUNNER_DIR`), the same directory the `mongodb-runner` CLI uses, +so clusters started by either one show up in both. Logs are written to +`/logs` unless `logDir` is passed to `mongodb_start_cluster`. + +`mongodb_start_cluster` covers every topology `mongodb-runner` supports: + +- `standalone`: a single mongod. +- `replset`: secondaries/arbiters, or explicit `rsMembers` for custom + priorities, tags (e.g. Atlas-style `{ "nodeType": "ANALYTICS" }` analytics + nodes) and per-member arguments. +- `sharded`: shard count or per-shard replica set configuration, plus one or + more mongos routers via `mongosArgs`. +- DSC: pass `slsDir` to back the cluster with the SLS disaggregated-storage + layer. Requires Docker with compose v2 and a DSC-capable mongod. + +Starting a cluster is a blocking call (it returns the id and connection string), +but it emits `notifications/progress` updates when the client sends a progress +token with the request. + +### Database tools (official MongoDB MCP server) + +Database operations are not reimplemented here. The plugin registers the +official `mongodb-mcp-server` as a second MCP server (`mongodb`), which provides +`find`, `aggregate`, `count`, `insert-many`, `update-many`, `delete-many`, +collection and index management, schema inspection, `export`, `mongodb-logs` +and more. + +Connect it to a cluster started above, using the connection string the start +tool returned: + +``` +mongodb_connect { "connectionString": "mongodb://127.0.0.1:27017/", "connectionName": "fixtures" } +→ { "connectionId": "fixtures-" } +``` + +Every database tool then takes that `connectionId`, so several clusters can be +connected at once. The connection string is visible in the conversation — fine +for the local, credential-free clusters this server starts, but avoid it for +clusters created with `users` or `oidc`. + +### DevTools-only tools + +These have no equivalent in the official server: + +| Tool | Description | +| ---------------------- | --------------------------------------------------------------------- | +| `mongodb_run_command` | Runs any database command (fail points, `collMod`, `distinct`, ...). | +| `mongodb_save_fixture` | Writes a collection's documents to an Extended JSON file. | +| `mongodb_load_fixture` | Inserts documents from such a file, optionally dropping first. | +| `mongodb_fail_point` | Configures a server fail point. Needs test commands enabled at start. | + +Fixture files are canonical Extended JSON and default to +`.devtools-mcp/fixtures/..ejson` in the working +directory, so they can be committed with the repository. + +### Workflow + +1. `mongodb_start_cluster` with `{ "topology": "standalone" }` → cluster id and + connection string. +2. `mongodb_connect` with that connection string → `connectionId`. +3. Seed and inspect with the official database tools (`insert-many`, `find`, + `aggregate`, ...). +4. `mongodb_save_fixture` to keep the data, `mongodb_load_fixture` to restore + it, `mongodb_fail_point` to simulate failures. +5. `mongodb_stop_cluster`. If a start crashed, `mongodb_get_cluster` reports it + as not reachable and `mongodb_prune_stale_clusters` removes the entry. + +## Usage + +```sh +npx @mongodb-js/devtools-mcp-server +``` + +### OpenCode + +The package's default export is an OpenCode plugin. Install it: + +```sh +opencode plugin add 'github:mongodb-js/devtools-shared#main::path:packages/devtools-mcp-server' +# or, once published: +opencode plugin add @mongodb-js/devtools-mcp-server +``` + +The plugin entry is plain ESM (`opencode-plugin.mjs`), so installing from git +needs no build step. The MCP server binary (`dist/bin.js`) is produced at publish +time; when the package is installed from a source checkout without `dist`, the +plugin falls back to `npx -y @mongodb-js/devtools-mcp-server`. Use the +`serverCommand` option to point at a local build instead. + +The plugin registers servers through OpenCode's transforms (nothing is written to +your configuration) plus the two bundled skills: + +| MCP server | Registered when | +| ---------------------------- | ------------------------------------------------------------------- | +| `devtools` | always — bundled `dist/bin.js`, or `npx` when installed from source | +| `mongodb` (official) | `mongodbServerCommand` is not `false` | +| `chrome-devtools` (official) | `chromeDevtoolsServerCommand` is not `false` | +| `devprod-mcp` | `devprodMcpUrl` or `DEVPROD_MCP_URL` is set | +| `glean-enterprise` | `gleanEnterpriseUrl` or `GLEAN_ENTERPRISE_URL` is set | + +Check the result with `opencode mcp list`. Remote servers need a sign-in the +first time: open `/mcps` in OpenCode and complete the OAuth flow. + +The plugin never replaces an MCP server you configured by hand: each entry above +is added only when that server name is not already present in your config. + +#### Plugin options + +| Option | Default | Description | +| ----------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `serverCommand` | bundled `dist/bin.js`, else `npx -y @mongodb-js/devtools-mcp-server` | Command that starts the DevTools server. | +| `mongodbServerCommand` | `["npx", "-y", "mongodb-mcp-server@3"]` | Command for the official MongoDB server, or `false` to skip it. | +| `chromeDevtoolsServerCommand` | `["npx", "-y", "chrome-devtools-mcp@1"]` | Command for the official Chrome DevTools server, or `false` to skip it. Add `--browser-url` to attach to Compass. | +| `devprodMcpUrl` | none | Internal devprod-mcp endpoint. No default; falls back to `DEVPROD_MCP_URL`. | +| `gleanEnterpriseUrl` | none | Internal Glean endpoint. No default; falls back to `GLEAN_ENTERPRISE_URL`. | + +#### Configuring the plugin + +`opencode plugin add` records the package as a **string** in the global config. +To pass options, replace that entry with the object form in +`~/.config/opencode/opencode.json(c)` (or in a project `opencode.json(c)` or +`.opencode/opencode.json(c)` if you only want it for one repository): + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "@mongodb-js/devtools-mcp-server", + "options": { + "serverCommand": ["node", "/path/to/dist/bin.js"], + "mongodbServerCommand": ["npx", "-y", "mongodb-mcp-server@3.3.0"], + "chromeDevtoolsServerCommand": [ + "npx", + "-y", + "chrome-devtools-mcp@1", + "--browser-url", + "http://127.0.0.1:9222", + ], + "devprodMcpUrl": "https:///mcp", + "gleanEnterpriseUrl": "https:///mcp", + }, + }, + ], +} +``` + +Keep only one entry per plugin: two entries share the same plugin ID (`devtools`) +and do not merge. + +#### Internal endpoints without editing config + +`devprodMcpUrl` and `gleanEnterpriseUrl` have **no defaults** because the +endpoints are internal. Supply them in one of these ways: + +- **Config** — the object entry above. Durable, and applies to every project on + the machine. +- **Environment** — export `DEVPROD_MCP_URL` and/or `GLEAN_ENTERPRISE_URL` in + your shell profile. Options win over environment variables. The environment is + read when the plugin loads, so run `opencode service restart` after changing + it; config changes reload on save (or with `/reload`). +- **Separate entries** — leave the plugin options out and add the servers + directly, e.g. `opencode mcp add devprod-mcp --global --url `. The + plugin does not register them when neither the option nor the environment + variable is set, so there is no duplicate. + +#### Disabling or pinning servers + +Set a command to `false` to skip that server, or replace the command to pin a +version: + +```jsonc +{ + "plugins": [ + { + "package": "@mongodb-js/devtools-mcp-server", + "options": { + "mongodbServerCommand": false, + "chromeDevtoolsServerCommand": false, + }, + }, + ], +} +``` + +#### Bundled skills + +The plugin also registers `devtools-mcp-workflows` (cluster → connect → fixtures +→ fail points) and `start-compass-with-devtools` (attaching the chrome-devtools +MCP server to a Compass build). + +### Programmatic use + +The library API (cluster and database helpers, `createServer`) is exported from +the `server` subpath, since the package root is the OpenCode plugin: + +```ts +import { createServer } from '@mongodb-js/devtools-mcp-server/server'; +``` + +## Development + +```sh +npm run compile +npm test +``` + +## License + +Apache 2.0 diff --git a/packages/devtools-mcp-server/opencode-plugin.mjs b/packages/devtools-mcp-server/opencode-plugin.mjs new file mode 100644 index 00000000..782d4f0f --- /dev/null +++ b/packages/devtools-mcp-server/opencode-plugin.mjs @@ -0,0 +1,232 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** npm package that provides the DevTools MCP server (this package). */ +export const SERVER_PACKAGE = '@mongodb-js/devtools-mcp-server'; + +/** npm package of the official MongoDB MCP server. */ +export const MONGODB_SERVER_PACKAGE = 'mongodb-mcp-server'; + +/** npm package of the official Chrome DevTools MCP server. */ +export const CHROME_DEVTOOLS_SERVER_PACKAGE = 'chrome-devtools-mcp'; + +/** Environment variables used when the URL options are not set. */ +export const DEVPROD_MCP_URL_ENV = 'DEVPROD_MCP_URL'; +export const GLEAN_ENTERPRISE_URL_ENV = 'GLEAN_ENTERPRISE_URL'; + +/** Skills shipped with the package, relative to the package root. */ +export const SKILL_FILES = [ + { + id: 'start-compass-with-devtools', + file: 'start-compass-with-devtools/SKILL.md', + }, + { id: 'devtools-mcp-workflows', file: 'devtools-mcp-workflows/SKILL.md' }, +]; + +/** + * The server binary built into the package (`dist/bin.js`). npm installs from + * the registry include the build; a checkout installed from git does not, and + * the plugin falls back to `npx`. + */ +const OWN_BIN_PATH = fileURLToPath(new URL('dist/bin.js', import.meta.url)); + +function isStringArray(value) { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((entry) => typeof entry === 'string') + ); +} + +export function resolveServerCommand( + options = {}, + { ownBinPath = OWN_BIN_PATH, exists = existsSync } = {}, +) { + const command = options.serverCommand; + if (isStringArray(command)) { + return command; + } + return exists(ownBinPath) + ? ['node', ownBinPath] + : ['npx', '-y', SERVER_PACKAGE]; +} + +export function resolveMongodbServerCommand(options = {}) { + const command = options.mongodbServerCommand; + if (command === false) { + return undefined; + } + return isStringArray(command) + ? command + : ['npx', '-y', `${MONGODB_SERVER_PACKAGE}@3`]; +} + +export function resolveChromeDevtoolsServerCommand(options = {}) { + const command = options.chromeDevtoolsServerCommand; + if (command === false) { + return undefined; + } + return isStringArray(command) + ? command + : ['npx', '-y', `${CHROME_DEVTOOLS_SERVER_PACKAGE}@1`]; +} + +/** + * Remote servers are only registered when their URL is provided: the endpoints + * are internal, so the package cannot ship defaults for them. + */ +export function remoteServerConfig(value) { + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + return { type: 'remote', url: url.toString() }; + } catch { + return undefined; + } +} + +/** + * Resolves a remote server from the plugin option first and the environment + * variable second, so the URL can be supplied without editing the config. + */ +export function resolveRemoteServer( + options, + optionName, + envName, + env = process.env, +) { + return ( + remoteServerConfig(options[optionName]) ?? remoteServerConfig(env[envName]) + ); +} + +export function mcpServerConfig(options = {}) { + return { type: 'local', command: resolveServerCommand(options) }; +} + +export function mongodbMcpServerConfig(options = {}) { + const command = resolveMongodbServerCommand(options); + return command ? { type: 'local', command } : undefined; +} + +export function chromeDevtoolsMcpServerConfig(options = {}) { + const command = resolveChromeDevtoolsServerCommand(options); + return command ? { type: 'local', command } : undefined; +} + +function parseSkillFile(content) { + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content); + if (!frontmatter) { + return undefined; + } + + const fields = {}; + for (const line of frontmatter[1].split(/\r?\n/)) { + const field = /^([\w-]+):\s*(.*)$/.exec(line); + if (field && field[2]) { + fields[field[1]] = field[2].trim(); + } + } + if (!fields.name) { + return undefined; + } + + return { + name: fields.name, + ...(fields.description ? { description: fields.description } : {}), + body: content.slice(frontmatter[0].length).trim(), + }; +} + +/** + * Reads the skills bundled with the package. Failures are ignored so a partial + * install still registers the MCP servers. + */ +export function loadSkills() { + const skills = []; + + for (const { id, file } of SKILL_FILES) { + try { + const path = fileURLToPath(new URL(`./skills/${file}`, import.meta.url)); + const parsed = parseSkillFile(readFileSync(path, 'utf8')); + if (!parsed) { + continue; + } + skills.push({ + id, + name: parsed.name, + ...(parsed.description ? { description: parsed.description } : {}), + path, + content: parsed.body, + }); + } catch { + // Optional: the MCP servers are still useful without the skills. + } + } + + return skills; +} + +/** + * Registers with OpenCode: + * + * - the `devtools` MCP server (cluster lifecycle, fixtures, fail points); + * - the official `mongodb` MCP server (database tools); + * - the official `chrome-devtools` MCP server (browser/Compass debugging); + * - the internal `devprod-mcp` and `glean-enterprise` servers, only when + * their URLs are provided as plugin options or environment variables; + * - the skills shipped in this package. + * + * All registrations are transforms rather than config writes, so removing or + * disabling the plugin removes them again. + */ +const plugin = { + id: 'devtools', + async setup(ctx) { + const options = ctx.options ?? {}; + const mongodb = mongodbMcpServerConfig(options); + const chromeDevtools = chromeDevtoolsMcpServerConfig(options); + const devprod = resolveRemoteServer( + options, + 'devprodMcpUrl', + DEVPROD_MCP_URL_ENV, + ); + const glean = resolveRemoteServer( + options, + 'gleanEnterpriseUrl', + GLEAN_ENTERPRISE_URL_ENV, + ); + const skills = loadSkills(); + const servers = [ + ['devtools', mcpServerConfig(options)], + ['mongodb', mongodb], + ['chrome-devtools', chromeDevtools], + ['devprod-mcp', devprod], + ['glean-enterprise', glean], + ]; + + await ctx.mcp.transform((editor) => { + for (const [name, config] of servers) { + // Never replace a server the user already configured by hand. + if (config && !editor.get(name)) { + editor.set(name, config); + } + } + }); + + if (skills.length > 0) { + await ctx.skill.transform((editor) => { + for (const skill of skills) { + editor.add(skill); + } + }); + } + }, +}; + +export default plugin; diff --git a/packages/devtools-mcp-server/package.json b/packages/devtools-mcp-server/package.json new file mode 100644 index 00000000..29b55407 --- /dev/null +++ b/packages/devtools-mcp-server/package.json @@ -0,0 +1,89 @@ +{ + "name": "@mongodb-js/devtools-mcp-server", + "version": "0.1.0", + "description": "Model Context Protocol server for MongoDB DevTools.", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile", + "compile": "tsc -p tsconfig.json && gen-esm-wrapper ./dist/index.js ./dist/.esm-wrapper.mjs", + "typecheck": "tsc --noEmit", + "eslint": "eslint", + "prettier": "prettier", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run prettier -- --write ." + }, + "main": "opencode-plugin.mjs", + "exports": { + ".": { + "default": "./opencode-plugin.mjs" + }, + "./server": { + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/.esm-wrapper.mjs" + } + }, + "./bin": "./dist/bin.js", + "./package.json": "./package.json" + }, + "bin": { + "devtools-mcp-server": "dist/bin.js" + }, + "files": [ + "dist", + "skills", + "opencode-plugin.mjs", + "LICENSE" + ], + "license": "Apache-2.0", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/devtools-shared", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/devtools-shared.git" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.32.2", + "@mongodb-js/mongodb-runner": "^8.4.9", + "mongodb": "~7.5.0", + "zod": "^4.6.7" + }, + "devDependencies": { + "@mongodb-js/eslint-config-devtools": "^0.11.15", + "@mongodb-js/mocha-config-devtools": "^3.0.0", + "@mongodb-js/prettier-config-devtools": "^1.0.6", + "@mongodb-js/tsconfig-devtools": "^1.1.5", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.1.1", + "@types/node": "^26.6.6", + "chai": "^4.5.0", + "depcheck": "^1.4.7", + "eslint": "^7.25.0 || ^8.0.0", + "gen-esm-wrapper": "^1.1.3", + "mocha": "^12.0.3", + "nyc": "^18.0.0", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/devtools-mcp-server/skills/devtools-mcp-workflows/SKILL.md b/packages/devtools-mcp-server/skills/devtools-mcp-workflows/SKILL.md new file mode 100644 index 00000000..8c30cacf --- /dev/null +++ b/packages/devtools-mcp-server/skills/devtools-mcp-workflows/SKILL.md @@ -0,0 +1,117 @@ +--- +name: DevTools MCP workflows +description: Use the devtools and mongodb MCP servers together to start local MongoDB clusters, connect to them, seed and save fixtures, simulate failures, read server logs and tear everything down. Use when a task needs a real MongoDB deployment, reproducible data, or a failure scenario that unit tests cannot cover. +--- + +# DevTools MCP workflows + +The `devtools` server owns the cluster lifecycle; the official `mongodb` server +owns database operations. The bridge is a connection string plus a +`connectionId`. + +## Prerequisites + +- Both servers are connected: `opencode mcp list` shows `devtools` and + `mongodb`. Installing `@mongodb-js/devtools-mcp-server` as an OpenCode plugin + registers both (and `chrome-devtools` for browser work). +- Node.js >= 24.22.2. + +## 1. Start a cluster + +``` +mongodb_start_cluster { "topology": "standalone" } +``` + +Useful options: + +- `replset`: `secondaries`, `arbiters`, or `rsMembers` for priorities and tags + (analytics nodes are `{ "tags": { "nodeType": "ANALYTICS" }, "priority": 0 }`). +- `sharded`: `shards` (count or per-shard config) and `mongosArgs`. +- `slsDir` for DSC clusters (needs Docker and a DSC-capable build). +- `version`, `downloadUrl`, `binDir`, `docker` to choose the server build. +- `args: ["--setParameter", "enableTestCommands=true"]` when fail points are + needed. +- `version` is optional; the first start downloads binaries and can take + minutes. Progress notifications are sent if the client asked for them. + +The result contains the cluster `id` and its `connectionString`. Logs go to +`/logs` by default. + +## 2. Connect the database server + +``` +mongodb_connect { "connectionString": "", "connectionName": "fixtures" } +→ { "connectionId": "..." } +``` + +Reuse that `connectionId` for every database tool. Connect several clusters with +different `connectionName`s to switch between them. + +The connection string is visible in the conversation. This is intended for the +local, credential-free clusters started here — avoid it for clusters started +with `users` or `oidc`. + +## 3. Work with data + +The official server provides `find`, `aggregate`, `count`, `insert-many`, +`update-many`, `delete-many`, `create-index`/`drop-index`, +`drop-collection`/`drop-database`, `list-collections`, `collection-indexes`, +`collection-schema`, `export` and more. All of them take `connectionId`. + +- `find` defaults to 10 documents and a 1 MB response limit; pass `limit` and a + `responseBytesLimit` when checking larger result sets. +- `export` writes canonical or relaxed EJSON to the official server's export + directory. + +## 4. Fixtures (devtools server) + +Fixtures are cluster-id addressed and default to +`.devtools-mcp/fixtures/..ejson` in the working directory, +so they can be committed. + +``` +mongodb_save_fixture { "clusterId": "...", "database": "app", "collection": "users", "filter": { "active": true } } +mongodb_load_fixture { "clusterId": "...", "database": "app", "collection": "users", "drop": true } +``` + +Files are canonical Extended JSON: `{ "$oid": ... }`, `{ "$date": ... }` and +`{ "$numberLong": ... }` survive the round trip. + +## 5. Simulate failures + +Start the cluster with test commands enabled, then: + +``` +mongodb_fail_point { "clusterId": "...", "name": "failCommand", "mode": "alwaysOn", + "data": { "failCommands": ["insert"], "errorCode": 11600 } } +``` + +Turn it off with `"mode": "off"`. Without `enableTestCommands` the server answers +`no such command: 'configureFailPoint'`. + +## 6. Escape hatches (devtools server) + +`mongodb_run_command` takes a raw command when no dedicated tool exists: +`distinct`, `collMod`, `configureFailPoint`, `validate`, and so on. + +``` +mongodb_run_command { "clusterId": "...", "database": "app", "command": { "distinct": "users", "key": "name" } } +``` + +## 7. Inspect and tear down + +- `mongodb_get_cluster` — reachable or not, servers, version, variant, log dir. +- `mongodb_cluster_logs` — last lines of every mongod/mongos log, useful when a + start or a test failed. +- `mongodb_stop_cluster` — stop one cluster by id, or all of them. +- `mongodb_prune_stale_clusters` — drop registry entries for clusters that are + no longer reachable (a crashed start leaves one behind). + +## Pitfalls + +- Database tools fail with an unknown connection if `mongodb_connect` was not + called or the cluster was stopped. +- `mongodb_stop_cluster` deletes the registry entry; `mongodb_list_clusters` + afterwards no longer shows it. +- One MCP server may be configured with a read-only mode; check before assuming + writes will work. diff --git a/packages/devtools-mcp-server/skills/start-compass-with-devtools/SKILL.md b/packages/devtools-mcp-server/skills/start-compass-with-devtools/SKILL.md new file mode 100644 index 00000000..8610f787 --- /dev/null +++ b/packages/devtools-mcp-server/skills/start-compass-with-devtools/SKILL.md @@ -0,0 +1,96 @@ +--- +name: Start Compass with DevTools +description: Launch a Compass development build with Chromium remote debugging enabled and attach the chrome-devtools MCP server, so the agent can inspect the renderer, read console errors and take screenshots. Use when debugging Compass UI or Electron behavior. +--- + +# Start Compass with DevTools + +Two ways to get DevTools into Compass. Use the built-in one when a human is +inspecting; use the MCP attach when the agent needs to drive or observe the +renderer. + +## Option A: built-in DevTools (no MCP) + +Settings → General → Enable DevTools → Save, then toggle DevTools with `⌘⌥I` +(macOS), `Ctrl+Shift+I` (Windows/Linux), or View → Toggle DevTools. Good for +manual inspection; the agent cannot drive it. + +## Option B: attach chrome-devtools-mcp (Electron renderer) + +1. Start Compass from its repository with Chromium remote debugging enabled. + Electron accepts Chromium switches, and Compass forwards `ELECTRON_EXTRA_ARGS`: + + ```sh + ELECTRON_EXTRA_ARGS="--remote-debugging-port=9222" npm start + ``` + + Check the Compass development guide if the flag has changed — `9222` is an + example port, not a guaranteed default. + +2. Verify the endpoint answers before configuring anything: + + ```sh + curl -s http://127.0.0.1:9222/json/version + ``` + + A JSON payload with `Browser` means the MCP server can attach. Compass must + already be running whenever the tools are used. + +3. The `chrome-devtools` MCP server is registered by the devtools plugin. To + point it at Compass, either override the plugin option or register a + dedicated entry (keep the generic one untouched so the agent can still launch + its own throwaway Chrome): + + ```jsonc + { + "options": { + "chromeDevtoolsServerCommand": [ + "npx", + "-y", + "chrome-devtools-mcp@1", + "--browser-url", + "http://127.0.0.1:9222", + ], + }, + } + ``` + + or: + + ```sh + opencode mcp add chrome-devtools-compass --global -- \ + npx -y chrome-devtools-mcp@1 --browser-url http://127.0.0.1:9222 + opencode mcp list + ``` + +4. From then on the agent can reload the page, read console messages, inspect + network requests and take screenshots against the running Compass. + +## Option C: Electron main process + +The renderer attach above does not expose the main process. To debug it, forward +`--inspect` or `--inspect-brk` instead and attach from `chrome://inspect`: + +```sh +ELECTRON_EXTRA_ARGS="--inspect=9229" npm start +``` + +## Cautions + +- Attaching to an existing session inherits everything in it: logged-in + accounts, cookies, local storage. Only attach sessions the agent may see. +- Chrome proper (136+) requires `--user-data-dir` alongside + `--remote-debugging-port`; Electron apps use their own user data directory, + so this does not automatically apply, but expect similar restrictions if + Chromium tightens it. +- A stale `--browser-url` entry fails every browser tool with "Could not connect + to Chrome". That is expected when Compass is not running with debugging + enabled; use the generic entry in that case. +- Compass must be started by the developer, not by the agent: it is an Electron + desktop app, not a headless browser. + +## Done means + +- `curl -s http://127.0.0.1:9222/json/version` returns a `Browser` payload. +- `opencode mcp list` shows the Compass entry as connected. +- A trivial browser tool (navigate or screenshot) succeeds against Compass. diff --git a/packages/devtools-mcp-server/src/bin.ts b/packages/devtools-mcp-server/src/bin.ts new file mode 100644 index 00000000..540b72c7 --- /dev/null +++ b/packages/devtools-mcp-server/src/bin.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createServer } from './index'; + +async function main(): Promise { + const server = createServer(); + await server.connect(new StdioServerTransport()); +} + +main().catch((err) => { + // stdout carries the MCP protocol, so diagnostics must go to stderr. + // eslint-disable-next-line no-console + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/devtools-mcp-server/src/clusters.spec.ts b/packages/devtools-mcp-server/src/clusters.spec.ts new file mode 100644 index 00000000..001d9c9c --- /dev/null +++ b/packages/devtools-mcp-server/src/clusters.spec.ts @@ -0,0 +1,326 @@ +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { expect } from 'chai'; +import assert from 'assert'; +import type { + MongoClusterOptions, + MongoClusterSerializedProperties, +} from '@mongodb-js/mongodb-runner'; +import { createClusters, type RunnerApi, type StoredCluster } from './clusters'; + +type StartOptions = MongoClusterOptions & { id?: string; runnerDir: string }; + +function storedCluster({ + id, + topology = 'standalone', + replSetName, + startedAt = '2026-01-02T00:00:00.000Z', + serverUuids = [`${id}-uuid`], + logDir, +}: { + id: string; + topology?: 'standalone' | 'replset' | 'sharded'; + replSetName?: string; + startedAt?: string; + serverUuids?: string[]; + logDir?: string; +}): StoredCluster { + return { + id, + connectionString: 'mongodb://127.0.0.1:27017/', + filepath: `/runner/m-${id}.json`, + serialized: { + topology, + ...(replSetName ? { replSetName } : {}), + servers: serverUuids.map((uuid, index) => ({ + _id: uuid, + host: '127.0.0.1', + port: 27017 + index, + startTime: startedAt, + hasInsertedMetadataCollEntry: true, + })), + shards: [], + defaultConnectionOptions: {}, + users: [], + ...(logDir ? { options: { topology, tmpDir: '/tmp', logDir } } : {}), + } as MongoClusterSerializedProperties, + }; +} + +function createFakeRunner() { + const started: { options: StartOptions; args?: string[] }[] = []; + const stopped: { runnerDir: string; id?: string; all?: boolean }[] = []; + const removed: string[] = []; + let registry: StoredCluster[] = []; + + const runner: RunnerApi = { + start: async (options, args) => { + started.push({ options, args }); + const id = options.id ?? 'generated-id'; + const replSetName = + options.topology === 'replset' ? 'replSet-test' : undefined; + registry.push( + storedCluster({ id, topology: options.topology, replSetName }), + ); + return { + cluster: { + connectionString: `mongodb://127.0.0.1:27017/?replicaSet=${replSetName ?? ''}`, + serialize: () => ({ topology: options.topology, replSetName }), + }, + id, + }; + }, + instances: async function* () { + yield* registry; + }, + stop: async (options) => { + stopped.push(options); + registry = registry.filter( + (entry) => !options.all && entry.id !== options.id, + ); + }, + remove: async (filepath) => { + removed.push(filepath); + registry = registry.filter((entry) => entry.filepath !== filepath); + }, + }; + + return { + runner, + started, + stopped, + removed, + setRegistry(entries: StoredCluster[]) { + registry = entries; + }, + }; +} + +const runnerDir = '/tmp/runner-dir'; + +describe('createClusters', function () { + it('starts a cluster with the generated id and maps the result', async function () { + const fake = createFakeRunner(); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + const result = await clusters.start({ topology: 'replset' }); + + expect(fake.started).to.have.lengthOf(1); + expect(fake.started[0].options).to.include({ + topology: 'replset', + runnerDir, + }); + expect(fake.started[0].options.id).to.be.undefined; + expect(fake.started[0].options.logDir).to.equal( + path.join(runnerDir, 'logs'), + ); + expect(result).to.deep.equal({ + id: 'generated-id', + connectionString: 'mongodb://127.0.0.1:27017/?replicaSet=replSet-test', + topology: 'replset', + replSetName: 'replSet-test', + logDir: path.join(runnerDir, 'logs'), + }); + }); + + it('passes the id and mongod args separately from the cluster options', async function () { + const fake = createFakeRunner(); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + await clusters.start({ + topology: 'standalone', + id: 'my-cluster', + args: ['--setParameter', 'enableTestCommands=1'], + }); + + expect(fake.started[0].options.id).to.equal('my-cluster'); + expect(fake.started[0].options).to.not.have.property('args'); + expect(fake.started[0].args).to.deep.equal([ + '--setParameter', + 'enableTestCommands=1', + ]); + }); + + it('lists registry entries sorted by start time', async function () { + const fake = createFakeRunner(); + fake.setRegistry([ + storedCluster({ id: 'b', startedAt: '2026-01-03T00:00:00.000Z' }), + storedCluster({ + id: 'a', + topology: 'replset', + replSetName: 'repl0', + startedAt: '2026-01-02T00:00:00.000Z', + }), + ]); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + const result = await clusters.list(); + + expect(result).to.deep.equal([ + { + id: 'a', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'replset', + replSetName: 'repl0', + startedAt: '2026-01-02T00:00:00.000Z', + }, + { + id: 'b', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'standalone', + startedAt: '2026-01-03T00:00:00.000Z', + }, + ]); + }); + + it('stops a single cluster by id', async function () { + const fake = createFakeRunner(); + fake.setRegistry([storedCluster({ id: 'a' })]); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + const result = await clusters.stop({ id: 'a' }); + + expect(result).to.deep.equal({ stopped: ['a'] }); + expect(fake.stopped).to.deep.equal([{ runnerDir, id: 'a' }]); + }); + + it('rejects an unknown cluster id', async function () { + const fake = createFakeRunner(); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + await assert.rejects( + clusters.stop({ id: 'missing' }), + /No running cluster with id "missing"/, + ); + expect(fake.stopped).to.be.empty; + }); + + it('stops every cluster when all is set', async function () { + const fake = createFakeRunner(); + fake.setRegistry([storedCluster({ id: 'a' }), storedCluster({ id: 'b' })]); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + const result = await clusters.stop({ all: true }); + + expect(result).to.deep.equal({ stopped: ['a', 'b'] }); + expect(fake.stopped).to.deep.equal([{ runnerDir, all: true }]); + }); + + it('requires exactly one of id and all', async function () { + const fake = createFakeRunner(); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + await assert.rejects( + clusters.stop({}), + /Either "id" or "all: true" is required/, + ); + await assert.rejects( + clusters.stop({ id: 'a', all: true }), + /"id" and "all" cannot be combined/, + ); + }); + + it('reports cluster details when the cluster is reachable', async function () { + const fake = createFakeRunner(); + fake.setRegistry([ + storedCluster({ + id: 'a', + topology: 'replset', + replSetName: 'repl0', + logDir: '/logs', + }), + ]); + const clusters = createClusters({ + runnerDir, + runner: fake.runner, + inspect: async () => ({ version: '8.2.1', variant: 'community' }), + }); + + const result = await clusters.get({ id: 'a' }); + + expect(result).to.include({ + id: 'a', + topology: 'replset', + replSetName: 'repl0', + running: true, + version: '8.2.1', + variant: 'community', + logDir: '/logs', + }); + expect(result.servers).to.deep.equal([ + { host: '127.0.0.1', port: 27017, type: 'mongod' }, + ]); + }); + + it('reports a stale registry entry as not running', async function () { + const fake = createFakeRunner(); + fake.setRegistry([storedCluster({ id: 'a' })]); + const clusters = createClusters({ + runnerDir, + runner: fake.runner, + inspect: async () => { + throw new Error('connect ECONNREFUSED 127.0.0.1:27017'); + }, + }); + + const result = await clusters.get({ id: 'a' }); + + expect(result.running).to.be.false; + expect(result.error).to.match(/ECONNREFUSED/); + }); + + it('prunes only the clusters that are not reachable', async function () { + const fake = createFakeRunner(); + fake.setRegistry([ + storedCluster({ id: 'alive' }), + storedCluster({ id: 'dead' }), + ]); + const clusters = createClusters({ + runnerDir, + runner: fake.runner, + inspect: async (serialized) => { + if (serialized.servers[0]._id === 'dead-uuid') { + throw new Error('connect ECONNREFUSED'); + } + return { version: '8.2.1', variant: 'community' }; + }, + }); + + const result = await clusters.prune(); + + expect(result).to.deep.equal({ removed: ['dead'] }); + expect(fake.removed).to.deep.equal(['/runner/m-dead.json']); + }); + + it('reads the tail of every server log', async function () { + const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-logs-')); + const logFile = path.join( + logDir, + 'mongod-uuid-a-2026-01-02T000000000Z.log', + ); + await fs.writeFile(logFile, 'first\nsecond\nthird\n'); + + try { + const fake = createFakeRunner(); + fake.setRegistry([ + storedCluster({ id: 'a', serverUuids: ['uuid-a'], logDir }), + ]); + const clusters = createClusters({ runnerDir, runner: fake.runner }); + + const logs = await clusters.logs({ id: 'a', tailLines: 2 }); + + expect(logs).to.deep.equal([ + { + host: '127.0.0.1', + port: 27017, + type: 'mongod', + path: logFile, + lines: ['second', 'third'], + }, + ]); + } finally { + await fs.rm(logDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/devtools-mcp-server/src/clusters.ts b/packages/devtools-mcp-server/src/clusters.ts new file mode 100644 index 00000000..c6829a37 --- /dev/null +++ b/packages/devtools-mcp-server/src/clusters.ts @@ -0,0 +1,264 @@ +import path from 'path'; +import { promises as fs } from 'fs'; +import { + instances as runnerInstances, + start as runnerStart, + stop as runnerStop, + type MongoClusterOptions, + type SLSMultiCellEnvironment, +} from '@mongodb-js/mongodb-runner'; +import { toRunnerOptions } from './options'; +import { + DEFAULT_RUNNER_DIR, + flattenServers, + resolveRunnerDir, + type StoredCluster, + type Topology, +} from './registry'; +import { + inspectCluster, + type ClusterInspector, + type ClusterVariant, +} from './connection'; +import { + DEFAULT_LOG_TAIL_LINES, + readClusterLogs, + type ServerLog, +} from './logs'; +import type { StartClusterInput, StopClusterInput } from './schema'; + +export { DEFAULT_RUNNER_DIR, resolveRunnerDir }; +export type { StoredCluster, Topology }; + +export type ClusterSummary = { + id: string; + connectionString: string; + topology: Topology; + replSetName?: string; + /** ISO timestamp of when the cluster's first server was started. */ + startedAt?: string; +}; + +export type StartClusterResult = ClusterSummary & { + logDir?: string; + sls?: { + ports: SLSMultiCellEnvironment['ports']; + services: SLSMultiCellEnvironment['services']; + }; +}; + +export type ClusterServer = { + host: string; + port?: number; + type: 'mongod' | 'mongos'; +}; + +export type ClusterDetails = ClusterSummary & { + running: boolean; + servers: ClusterServer[]; + logDir?: string; + version?: string; + variant?: ClusterVariant; + /** Why the cluster is not reachable, when running is false. */ + error?: string; +}; + +export type StopClustersResult = { + stopped: string[]; +}; + +export type PruneClustersResult = { + removed: string[]; +}; + +export type ClusterLogsInput = { + id: string; + tailLines?: number; +}; + +export interface Clusters { + start(input: StartClusterInput): Promise; + list(): Promise; + stop(input: StopClusterInput): Promise; + get(input: { id: string }): Promise; + prune(): Promise; + logs(input: ClusterLogsInput): Promise; +} + +/** The subset of `mongodb-runner` used here, narrow enough to be faked in tests. */ +export interface RunnerApi { + start( + options: MongoClusterOptions & { id?: string; runnerDir: string }, + args?: string[], + ): Promise<{ + cluster: { + connectionString: string; + serialize(): { topology: Topology; replSetName?: string }; + }; + id: string; + }>; + instances(options: { runnerDir: string }): AsyncIterable; + stop(options: { + runnerDir: string; + id?: string; + all?: boolean; + }): Promise; + /** Removes a stored cluster's metadata file. */ + remove(filepath: string): Promise; +} + +const defaultRunnerApi: RunnerApi = { + start: runnerStart, + instances: runnerInstances, + stop: runnerStop, + remove: (filepath) => fs.rm(filepath, { force: true }), +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function summarize(stored: StoredCluster): ClusterSummary { + const { topology, replSetName, servers } = stored.serialized; + const startedAt = servers[0]?.startTime; + return { + id: stored.id, + connectionString: stored.connectionString, + topology, + ...(replSetName ? { replSetName } : {}), + ...(startedAt ? { startedAt } : {}), + }; +} + +export function createClusters({ + runnerDir = resolveRunnerDir(), + runner = defaultRunnerApi, + inspect = inspectCluster, +}: { + runnerDir?: string; + runner?: RunnerApi; + inspect?: ClusterInspector; +} = {}): Clusters { + async function findInRegistry(id: string): Promise { + for await (const instance of runner.instances({ runnerDir })) { + if (instance.id === id) { + return instance; + } + } + throw new Error( + `No running cluster with id "${id}". Use mongodb_list_clusters to see the available ids.`, + ); + } + + async function start(input: StartClusterInput): Promise { + const { options, args, sls } = await toRunnerOptions(input, { + defaultLogDir: path.join(runnerDir, 'logs'), + }); + const { cluster, id } = await runner.start( + { ...options, id: input.id, runnerDir }, + args, + ); + const { topology, replSetName } = cluster.serialize(); + + return { + id, + connectionString: cluster.connectionString, + topology, + ...(replSetName ? { replSetName } : {}), + ...(options.logDir ? { logDir: options.logDir } : {}), + ...(sls ? { sls: { ports: sls.ports, services: sls.services } } : {}), + }; + } + + async function list(): Promise { + const clusters: ClusterSummary[] = []; + for await (const instance of runner.instances({ runnerDir })) { + clusters.push(summarize(instance)); + } + return clusters.sort( + (a, b) => + (a.startedAt ?? '').localeCompare(b.startedAt ?? '') || + a.id.localeCompare(b.id), + ); + } + + async function stop(input: StopClusterInput): Promise { + if (input.all && input.id !== undefined) { + throw new Error('"id" and "all" cannot be combined.'); + } + + const ids = (await list()).map((cluster) => cluster.id); + + if (input.all) { + if (ids.length === 0) { + return { stopped: [] }; + } + await runner.stop({ runnerDir, all: true }); + return { stopped: ids }; + } + + if (input.id === undefined) { + throw new Error( + 'Either "id" or "all: true" is required to stop clusters.', + ); + } + if (!ids.includes(input.id)) { + throw new Error( + `No running cluster with id "${input.id}". Use mongodb_list_clusters to see the available ids.`, + ); + } + + await runner.stop({ runnerDir, id: input.id }); + return { stopped: [input.id] }; + } + + async function get({ id }: { id: string }): Promise { + const stored = await findInRegistry(id); + const servers: ClusterServer[] = flattenServers(stored.serialized).map( + (server) => ({ + host: server.host ?? '127.0.0.1', + port: server.port, + type: server.isMongos ? 'mongos' : 'mongod', + }), + ); + const logDir = stored.serialized.options?.logDir; + const base = { + ...summarize(stored), + servers, + ...(logDir ? { logDir } : {}), + }; + + try { + const inspection = await inspect(stored.serialized); + return { ...base, running: true, ...inspection }; + } catch (error) { + return { ...base, running: false, error: errorMessage(error) }; + } + } + + /** Drops metadata for clusters that are not reachable, like `mongodb-runner prune`. */ + async function prune(): Promise { + const removed: string[] = []; + for await (const instance of runner.instances({ runnerDir })) { + const reachable = await inspect(instance.serialized).then( + () => true, + () => false, + ); + if (!reachable) { + await runner.remove(instance.filepath); + removed.push(instance.id); + } + } + return { removed }; + } + + async function logs({ + id, + tailLines = DEFAULT_LOG_TAIL_LINES, + }: ClusterLogsInput): Promise { + const stored = await findInRegistry(id); + return readClusterLogs(stored.serialized, { tailLines }); + } + + return { start, list, stop, get, prune, logs }; +} diff --git a/packages/devtools-mcp-server/src/connection.ts b/packages/devtools-mcp-server/src/connection.ts new file mode 100644 index 00000000..9d0aa9ca --- /dev/null +++ b/packages/devtools-mcp-server/src/connection.ts @@ -0,0 +1,46 @@ +import type { MongoClient } from 'mongodb'; +import { + MongoCluster, + type MongoClusterSerializedProperties, +} from '@mongodb-js/mongodb-runner'; + +/** Fail fast when the cluster is gone instead of waiting for the driver default (30s). */ +export const SERVER_SELECTION_TIMEOUT_MS = 5_000; + +export type ClusterVariant = 'enterprise' | 'community'; + +export interface ClusterInspection { + version: string; + variant: ClusterVariant; +} + +export type ClusterInspector = ( + serialized: MongoClusterSerializedProperties, +) => Promise; + +/** + * Reconnects through mongodb-runner's serialized cluster metadata so that + * authentication, TLS options, the replica set name and the topology's write + * concern are all applied. + */ +export async function withClusterClient( + serialized: MongoClusterSerializedProperties, + fn: (client: MongoClient) => Promise, +): Promise { + const cluster = await MongoCluster.deserialize(serialized); + return cluster.withClient(fn, { + serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, + }); +} + +/** Connects and reads the server version, which also answers "is it alive?". */ +export const inspectCluster: ClusterInspector = (serialized) => + withClusterClient(serialized, async (client) => { + const buildInfo = await client.db('admin').command({ buildInfo: 1 }); + return { + version: String(buildInfo.version), + variant: buildInfo.modules?.includes('enterprise') + ? 'enterprise' + : 'community', + }; + }); diff --git a/packages/devtools-mcp-server/src/database.spec.ts b/packages/devtools-mcp-server/src/database.spec.ts new file mode 100644 index 00000000..4603762b --- /dev/null +++ b/packages/devtools-mcp-server/src/database.spec.ts @@ -0,0 +1,209 @@ +import { expect } from 'chai'; +import assert from 'assert'; +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { BSON, type Document, type MongoClient } from 'mongodb'; +import { + createDatabase, + defaultFixturePath, + type WithClient, +} from './database'; + +type Call = { method: string; args: unknown[] }; + +function createFakeMongo( + options: { + findDocuments?: Document[]; + dropResult?: boolean; + commandResult?: Document; + } = {}, +) { + const calls: Call[] = []; + + const collection = { + find: (filter: Document, findOptions: unknown) => { + calls.push({ method: 'find', args: [filter, findOptions] }); + return { toArray: async () => options.findDocuments ?? [] }; + }, + insertMany: async (documents: Document[], insertOptions: unknown) => { + calls.push({ method: 'insertMany', args: [documents, insertOptions] }); + const insertedIds = Object.fromEntries( + documents.map((_, index) => [ + index, + new BSON.ObjectId('65f0c0ffee00c0ffee00c0ff'), + ]), + ); + return { insertedCount: documents.length, insertedIds }; + }, + drop: async () => { + calls.push({ method: 'drop', args: [] }); + return options.dropResult ?? true; + }, + }; + + const client = { + db: (databaseName: string) => ({ + collection: (collectionName: string) => { + calls.push({ + method: 'collection', + args: [databaseName, collectionName], + }); + return collection; + }, + command: async (command: Document) => { + calls.push({ method: 'command', args: [databaseName, command] }); + return options.commandResult ?? { ok: 1 }; + }, + }), + } as unknown as MongoClient; + + const withClient: WithClient = async (clusterId, fn) => { + calls.push({ method: 'withClient', args: [clusterId] }); + return fn(client); + }; + + return { calls, withClient }; +} + +function call(calls: Call[], method: string): Call { + const found = calls.find((entry) => entry.method === method); + if (!found) throw new Error(`Expected a ${method} call`); + return found; +} + +describe('createDatabase', function () { + it('runs commands against the requested database and serializes the result', async function () { + const { calls, withClient } = createFakeMongo({ + commandResult: { ok: 1, values: ['a'] }, + }); + const database = createDatabase({ withClient }); + + const result = await database.runCommand({ + clusterId: 'cluster-1', + database: 'admin', + command: { distinct: 'users', key: 'name' }, + }); + + expect(call(calls, 'withClient').args).to.deep.equal(['cluster-1']); + expect(call(calls, 'command').args).to.deep.equal([ + 'admin', + { distinct: 'users', key: 'name' }, + ]); + expect(result).to.deep.equal({ + result: { ok: { $numberInt: '1' }, values: ['a'] }, + }); + }); + + it('saves a fixture as canonical Extended JSON on disk', async function () { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fixture-')); + const filePath = path.join(dir, 'nested', 'users.ejson'); + + try { + const { withClient } = createFakeMongo({ + findDocuments: [ + { _id: new BSON.ObjectId('65f0c0ffee00c0ffee00c0ff'), n: 1 }, + ], + }); + const database = createDatabase({ withClient }); + + const result = await database.saveFixture({ + clusterId: 'cluster-1', + database: 'app', + collection: 'users', + path: filePath, + }); + + expect(result).to.deep.equal({ path: filePath, documentCount: 1 }); + const written = JSON.parse(await fs.readFile(filePath, 'utf8')); + expect(written).to.include({ database: 'app', collection: 'users' }); + expect(written.documents).to.deep.equal([ + { _id: { $oid: '65f0c0ffee00c0ffee00c0ff' }, n: { $numberInt: '1' } }, + ]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('loads a fixture, optionally dropping the collection first', async function () { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fixture-')); + const filePath = path.join(dir, 'users.ejson'); + await fs.writeFile( + filePath, + JSON.stringify([ + { _id: { $oid: '65f0c0ffee00c0ffee00c0ff' }, n: { $numberInt: '1' } }, + ]), + ); + + try { + const { calls, withClient } = createFakeMongo(); + const database = createDatabase({ withClient }); + + const result = await database.loadFixture({ + clusterId: 'cluster-1', + database: 'app', + collection: 'users', + path: filePath, + drop: true, + }); + + expect(result).to.deep.equal({ + path: filePath, + insertedCount: 1, + dropped: true, + }); + expect(calls.some((entry) => entry.method === 'drop')).to.be.true; + const [documents] = call(calls, 'insertMany').args as [ + Document[], + unknown, + ]; + expect(documents[0]._id).to.be.instanceOf(BSON.ObjectId); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('rejects a fixture file without a documents array', async function () { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fixture-')); + const filePath = path.join(dir, 'broken.ejson'); + await fs.writeFile(filePath, JSON.stringify({ nope: true })); + + try { + const { withClient } = createFakeMongo(); + const database = createDatabase({ withClient }); + + await assert.rejects( + database.loadFixture({ + clusterId: 'cluster-1', + database: 'app', + collection: 'users', + path: filePath, + }), + /does not contain a documents array/, + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('derives the default fixture path from the database and collection', function () { + expect(defaultFixturePath('app', 'users')).to.equal( + path.resolve(process.cwd(), '.devtools-mcp/fixtures/app.users.ejson'), + ); + }); + + it('fails with a helpful message for an unknown cluster id', async function () { + const database = createDatabase({ + runnerDir: '/tmp/devtools-mcp-server-missing-runner-dir', + }); + + await assert.rejects( + database.runCommand({ + clusterId: 'missing', + database: 'admin', + command: { ping: 1 }, + }), + /No running cluster with id "missing"/, + ); + }); +}); diff --git a/packages/devtools-mcp-server/src/database.ts b/packages/devtools-mcp-server/src/database.ts new file mode 100644 index 00000000..565642fd --- /dev/null +++ b/packages/devtools-mcp-server/src/database.ts @@ -0,0 +1,166 @@ +import path from 'path'; +import { promises as fs } from 'fs'; +import { MongoServerError, type MongoClient } from 'mongodb'; +import { findStoredCluster, resolveRunnerDir } from './registry'; +import { withClusterClient } from './connection'; +import { fromEjson, toEjson } from './ejson'; +import type { + LoadFixtureInput, + RunCommandInput, + SaveFixtureInput, +} from './schema'; + +export const FIXTURE_DIR = '.devtools-mcp/fixtures'; + +export type RunCommandResult = { + result: unknown; +}; + +export type SaveFixtureResult = { + path: string; + documentCount: number; +}; + +export type LoadFixtureResult = { + path: string; + insertedCount: number; + dropped: boolean; +}; + +/** + * The database operations that do not have an equivalent in the official + * MongoDB MCP server: arbitrary commands (e.g. fail points) and fixture files. + */ +export interface Database { + runCommand(input: RunCommandInput): Promise; + saveFixture(input: SaveFixtureInput): Promise; + loadFixture(input: LoadFixtureInput): Promise; +} + +/** + * Opens a client for a cluster id and closes it again once `fn` is done. This + * is the seam the tools are tested through; the default implementation + * resolves the id in the mongodb-runner registry. + */ +export type WithClient = ( + clusterId: string, + fn: (client: MongoClient) => Promise, +) => Promise; + +function isNamespaceNotFound(error: unknown): boolean { + return ( + error instanceof MongoServerError && + (error.codeName === 'NamespaceNotFound' || Number(error.code) === 26) + ); +} + +/** Fixtures without an explicit path go to `/.devtools-mcp/fixtures`. */ +export function defaultFixturePath( + database: string, + collection: string, +): string { + const safeName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, '_'); + return path.resolve( + process.cwd(), + FIXTURE_DIR, + `${safeName(database)}.${safeName(collection)}.ejson`, + ); +} + +export function createDatabase({ + runnerDir = resolveRunnerDir(), + withClient, +}: { runnerDir?: string; withClient?: WithClient } = {}): Database { + const client: WithClient = + withClient ?? + (async (clusterId, fn) => { + const stored = await findStoredCluster(runnerDir, clusterId); + return withClusterClient(stored.serialized, fn); + }); + + function collectionOf(input: { + clusterId: string; + database: string; + collection: string; + }) { + return (mongo: MongoClient) => + mongo.db(input.database).collection(input.collection); + } + + async function runCommand(input: RunCommandInput): Promise { + return client(input.clusterId, async (mongo) => { + const result = await mongo + .db(input.database) + .command(fromEjson(input.command)); + return { result: toEjson(result) }; + }); + } + + async function saveFixture( + input: SaveFixtureInput, + ): Promise { + const filePath = input.path + ? path.resolve(input.path) + : defaultFixturePath(input.database, input.collection); + + return client(input.clusterId, async (mongo) => { + const documents = await collectionOf(input)(mongo) + .find( + fromEjson(input.filter ?? {}), + input.limit ? { limit: input.limit } : {}, + ) + .toArray(); + const payload = { + database: input.database, + collection: input.collection, + savedAt: new Date().toISOString(), + documents: documents.map(toEjson), + }; + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`); + return { path: filePath, documentCount: documents.length }; + }); + } + + async function loadFixture( + input: LoadFixtureInput, + ): Promise { + const filePath = input.path + ? path.resolve(input.path) + : defaultFixturePath(input.database, input.collection); + + const parsed: unknown = JSON.parse(await fs.readFile(filePath, 'utf8')); + const documents = Array.isArray(parsed) + ? parsed + : (parsed as { documents?: unknown }).documents; + if (!Array.isArray(documents)) { + throw new Error( + `Fixture file "${filePath}" does not contain a documents array.`, + ); + } + + return client(input.clusterId, async (mongo) => { + const col = collectionOf(input)(mongo); + let dropped = false; + if (input.drop) { + dropped = await col.drop().catch((error: unknown) => { + if (isNamespaceNotFound(error)) return false; + throw error; + }); + } + const result = documents.length + ? await col.insertMany( + documents.map(fromEjson), + input.ordered === undefined ? {} : { ordered: input.ordered }, + ) + : undefined; + return { + path: filePath, + insertedCount: result?.insertedCount ?? 0, + dropped, + }; + }); + } + + return { runCommand, saveFixture, loadFixture }; +} diff --git a/packages/devtools-mcp-server/src/ejson.spec.ts b/packages/devtools-mcp-server/src/ejson.spec.ts new file mode 100644 index 00000000..3e11046d --- /dev/null +++ b/packages/devtools-mcp-server/src/ejson.spec.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import { BSON } from 'mongodb'; +import { fromEjson, toEjson } from './ejson'; + +describe('ejson', function () { + it('round-trips BSON types through canonical Extended JSON', function () { + const value = fromEjson({ + oid: { $oid: '65f0c0ffee00c0ffee00c0ff' }, + date: { $date: '2024-01-01T00:00:00.000Z' }, + long: { $numberLong: '9007199254740993' }, + int: { $numberInt: '42' }, + double: 1.5, + nested: [{ $oid: '65f0c0ffee00c0ffee00c0ff' }], + }); + + expect(value.oid).to.be.instanceOf(BSON.ObjectId); + expect(value.date).to.be.instanceOf(Date); + expect(value.long).to.be.instanceOf(BSON.Long); + expect(value.nested[0]).to.be.instanceOf(BSON.ObjectId); + + expect(toEjson(value)).to.deep.equal({ + oid: { $oid: '65f0c0ffee00c0ffee00c0ff' }, + date: { $date: { $numberLong: String(Date.UTC(2024, 0, 1)) } }, + long: { $numberLong: '9007199254740993' }, + int: { $numberInt: '42' }, + double: { $numberDouble: '1.5' }, + nested: [{ $oid: '65f0c0ffee00c0ffee00c0ff' }], + }); + }); + + it('accepts plain JSON and returns canonical numbers', function () { + const value = fromEjson({ a: 1, b: 'x', c: true, d: null }); + + expect(toEjson(value)).to.deep.equal({ + a: { $numberInt: '1' }, + b: 'x', + c: true, + d: null, + }); + }); +}); diff --git a/packages/devtools-mcp-server/src/ejson.ts b/packages/devtools-mcp-server/src/ejson.ts new file mode 100644 index 00000000..22c5ecdd --- /dev/null +++ b/packages/devtools-mcp-server/src/ejson.ts @@ -0,0 +1,21 @@ +import { BSON, type Document } from 'mongodb'; + +/** + * Converts JSON tool input into BSON values. Strict (non-relaxed) mode keeps + * `$numberLong`/`$numberInt`/`$numberDouble` intact instead of collapsing them + * to JavaScript numbers, while relaxed values such as `{"$date": "..."}` and + * plain numbers are still accepted. + */ +export function fromEjson(value: unknown): Document { + return BSON.EJSON.parse(JSON.stringify(value), { + relaxed: false, + }) as Document; +} + +/** + * Converts BSON values into canonical Extended JSON, keeping ObjectId, Date, + * Long, Decimal128 and friends intact on their way back to the agent. + */ +export function toEjson(value: unknown): unknown { + return BSON.EJSON.serialize(value, { relaxed: false }); +} diff --git a/packages/devtools-mcp-server/src/index.spec.ts b/packages/devtools-mcp-server/src/index.spec.ts new file mode 100644 index 00000000..65b702a4 --- /dev/null +++ b/packages/devtools-mcp-server/src/index.spec.ts @@ -0,0 +1,348 @@ +import { expect } from 'chai'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { Clusters } from './clusters'; +import type { Database } from './database'; +import { createServer } from './index'; + +function createFakeClusters(overrides: Partial = {}): Clusters { + return { + start: async (input) => ({ + id: input.id ?? 'generated-id', + connectionString: 'mongodb://127.0.0.1:27017/?replicaSet=repl0', + topology: input.topology, + replSetName: 'repl0', + }), + list: async () => [], + stop: async () => ({ stopped: [] }), + get: async (input) => ({ + id: input.id, + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'standalone', + running: true, + servers: [], + }), + prune: async () => ({ removed: [] }), + logs: async () => [], + ...overrides, + }; +} + +function createFakeDatabase(overrides: Partial = {}): Database { + return { + runCommand: async () => ({ result: {} }), + saveFixture: async () => ({ path: '/tmp/fixture.ejson', documentCount: 0 }), + loadFixture: async () => ({ + path: '/tmp/fixture.ejson', + insertedCount: 0, + dropped: false, + }), + ...overrides, + }; +} + +describe('createServer', function () { + let client: Client; + + async function connect( + clusters: Clusters, + database: Database = createFakeDatabase(), + ): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await createServer({ clusters, database }).connect(serverTransport); + client = new Client({ name: 'test-client', version: '1.0.0' }); + await client.connect(clientTransport); + } + + afterEach(async function () { + await client?.close(); + }); + + it('advertises the devtools tools', async function () { + await connect(createFakeClusters()); + + const { tools } = await client.listTools(); + + expect(tools.map((tool) => tool.name)).to.have.members([ + 'mongodb_start_cluster', + 'mongodb_list_clusters', + 'mongodb_get_cluster', + 'mongodb_cluster_logs', + 'mongodb_stop_cluster', + 'mongodb_prune_stale_clusters', + 'mongodb_run_command', + 'mongodb_save_fixture', + 'mongodb_load_fixture', + 'mongodb_fail_point', + ]); + }); + + it('passes start options through and returns id and connection string', async function () { + let received: unknown; + await connect( + createFakeClusters({ + start: async (input) => { + received = input; + return { + id: 'my-cluster', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: input.topology, + }; + }, + }), + ); + + const result = await client.callTool({ + name: 'mongodb_start_cluster', + arguments: { + topology: 'sharded', + id: 'my-cluster', + shards: 2, + mongosArgs: [['--setParameter', 'enableTestCommands=1']], + }, + }); + + expect(received).to.include({ + topology: 'sharded', + id: 'my-cluster', + shards: 2, + }); + expect(result.isError).to.be.undefined; + expect(result.structuredContent).to.deep.equal({ + id: 'my-cluster', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'sharded', + }); + }); + + it('sends progress notifications when the client asks for them', async function () { + await connect(createFakeClusters()); + const updates: { progress: number; message?: string }[] = []; + + const result = await client.callTool( + { + name: 'mongodb_start_cluster', + arguments: { topology: 'standalone' }, + }, + undefined, + { onprogress: (update) => updates.push(update) }, + ); + + expect(result.isError).to.be.undefined; + expect(updates).to.deep.equal([ + { + progress: 1, + message: + 'Starting standalone cluster. This can take several minutes if the MongoDB binaries need to be downloaded.', + }, + { progress: 2, message: 'Cluster "generated-id" is ready.' }, + ]); + }); + + it('rejects invalid start input before reaching the clusters implementation', async function () { + let called = false; + await connect( + createFakeClusters({ + start: async () => { + called = true; + throw new Error('should not be called'); + }, + }), + ); + + const result = await client.callTool({ + name: 'mongodb_start_cluster', + arguments: { topology: 'sharded', shards: 0 }, + }); + + expect(result.isError).to.be.true; + expect(called).to.be.false; + }); + + it('passes run_command input through and returns the result', async function () { + let received: unknown; + await connect( + createFakeClusters(), + createFakeDatabase({ + runCommand: async (input) => { + received = input; + return { result: { ok: { $numberInt: '1' } } }; + }, + }), + ); + + const result = await client.callTool({ + name: 'mongodb_run_command', + arguments: { + clusterId: 'cluster-1', + database: 'admin', + command: { configureFailPoint: 'failCommand', mode: 'off' }, + }, + }); + + expect(received).to.deep.equal({ + clusterId: 'cluster-1', + database: 'admin', + command: { configureFailPoint: 'failCommand', mode: 'off' }, + }); + expect(result.structuredContent).to.deep.equal({ + result: { ok: { $numberInt: '1' } }, + }); + }); + + it('lists clusters as structured content', async function () { + await connect( + createFakeClusters({ + list: async () => [ + { + id: 'a', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'standalone', + startedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + ); + + const result = await client.callTool({ + name: 'mongodb_list_clusters', + arguments: {}, + }); + + expect(result.structuredContent).to.deep.equal({ + clusters: [ + { + id: 'a', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'standalone', + startedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + + it('stops clusters and reports the stopped ids', async function () { + const stopInputs: unknown[] = []; + await connect( + createFakeClusters({ + stop: async (input) => { + stopInputs.push(input); + return { stopped: ['a'] }; + }, + }), + ); + + const result = await client.callTool({ + name: 'mongodb_stop_cluster', + arguments: { id: 'a' }, + }); + + expect(stopInputs).to.deep.equal([{ id: 'a' }]); + expect(result.structuredContent).to.deep.equal({ stopped: ['a'] }); + }); + + it('surfaces stop failures as tool errors', async function () { + await connect( + createFakeClusters({ + stop: async () => { + throw new Error('No running cluster with id "missing".'); + }, + }), + ); + + const result = await client.callTool({ + name: 'mongodb_stop_cluster', + arguments: { id: 'missing' }, + }); + + expect(result.isError).to.be.true; + expect(result.content).to.deep.equal([ + { type: 'text', text: 'No running cluster with id "missing".' }, + ]); + }); + + it('reports cluster status through mongodb_get_cluster', async function () { + await connect( + createFakeClusters({ + get: async (input) => ({ + id: input.id, + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'replset', + replSetName: 'repl0', + running: true, + servers: [{ host: '127.0.0.1', port: 27017, type: 'mongod' }], + version: '8.2.1', + variant: 'community', + }), + }), + ); + + const result = await client.callTool({ + name: 'mongodb_get_cluster', + arguments: { id: 'a' }, + }); + + expect(result.isError).to.be.undefined; + expect(result.structuredContent).to.deep.equal({ + id: 'a', + connectionString: 'mongodb://127.0.0.1:27017/', + topology: 'replset', + replSetName: 'repl0', + running: true, + servers: [{ host: '127.0.0.1', port: 27017, type: 'mongod' }], + version: '8.2.1', + variant: 'community', + }); + expect((result.content[0] as { text: string }).text).to.match( + /Cluster "a" is running \(MongoDB 8\.2\.1, community\)\./, + ); + }); + + it('prunes stale clusters and reads logs', async function () { + const calls: unknown[] = []; + await connect( + createFakeClusters({ + prune: async () => { + calls.push('prune'); + return { removed: ['dead'] }; + }, + logs: async (input) => { + calls.push(input); + return [ + { + host: '127.0.0.1', + port: 27017, + type: 'mongod', + path: '/logs/mongod-1.log', + lines: ['line one', 'line two'], + }, + ]; + }, + }), + ); + + const pruneResult = await client.callTool({ + name: 'mongodb_prune_stale_clusters', + arguments: {}, + }); + expect(pruneResult.structuredContent).to.deep.equal({ removed: ['dead'] }); + + const logsResult = await client.callTool({ + name: 'mongodb_cluster_logs', + arguments: { id: 'a', tailLines: 2 }, + }); + expect(calls).to.deep.equal(['prune', { id: 'a', tailLines: 2 }]); + expect(logsResult.structuredContent).to.deep.equal({ + logs: [ + { + host: '127.0.0.1', + port: 27017, + type: 'mongod', + path: '/logs/mongod-1.log', + lines: ['line one', 'line two'], + }, + ], + }); + }); +}); diff --git a/packages/devtools-mcp-server/src/index.ts b/packages/devtools-mcp-server/src/index.ts new file mode 100644 index 00000000..bb2edcb6 --- /dev/null +++ b/packages/devtools-mcp-server/src/index.ts @@ -0,0 +1,65 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { createClusters, type Clusters } from './clusters'; +import { createDatabase, type Database } from './database'; +import { registerTools } from './tools'; + +export { + createClusters, + DEFAULT_RUNNER_DIR, + resolveRunnerDir, +} from './clusters'; +export { createDatabase, defaultFixturePath, FIXTURE_DIR } from './database'; +export { + inspectCluster, + SERVER_SELECTION_TIMEOUT_MS, + withClusterClient, +} from './connection'; +export { findStoredCluster, flattenServers } from './registry'; +export type { Database, WithClient } from './database'; +export type { + Clusters, + ClusterDetails, + ClusterLogsInput, + ClusterServer, + ClusterSummary, + PruneClustersResult, + RunnerApi, + StartClusterResult, + StopClustersResult, + StoredCluster, + Topology, +} from './clusters'; +export type { + ClusterInspection, + ClusterInspector, + ClusterVariant, +} from './connection'; +export type { ServerLog } from './logs'; + +export interface CreateServerOptions { + /** Overridable for tests; defaults to the real mongodb-runner backed implementation. */ + clusters?: Clusters; + /** Overridable for tests; defaults to the real mongodb-runner backed implementation. */ + database?: Database; +} + +/** + * Creates the DevTools MCP server with the mongodb-runner cluster and data + * tools registered. No transport is attached, see `bin.ts` for the stdio + * entrypoint. + */ +export function createServer({ + clusters = createClusters(), + database = createDatabase(), +}: CreateServerOptions = {}): McpServer { + const server = new McpServer({ + name: 'devtools-mcp-server', + // Read at runtime so the reported version matches the installed package. + // eslint-disable-next-line @typescript-eslint/no-var-requires + version: require('../package.json').version, + }); + + registerTools(server, clusters, database); + + return server; +} diff --git a/packages/devtools-mcp-server/src/logs.spec.ts b/packages/devtools-mcp-server/src/logs.spec.ts new file mode 100644 index 00000000..fccf4014 --- /dev/null +++ b/packages/devtools-mcp-server/src/logs.spec.ts @@ -0,0 +1,114 @@ +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { expect } from 'chai'; +import assert from 'assert'; +import type { MongoClusterSerializedProperties } from '@mongodb-js/mongodb-runner'; +import { readClusterLogs, tailFile } from './logs'; + +function cluster({ + logDir, + servers, +}: { + logDir?: string; + servers: { uuid: string; port: number; isMongos?: boolean }[]; +}): MongoClusterSerializedProperties { + return { + topology: servers.some((server) => server.isMongos) + ? 'sharded' + : 'standalone', + servers: servers.map((server) => ({ + _id: server.uuid, + host: '127.0.0.1', + port: server.port, + isMongos: server.isMongos, + startTime: '2026-01-02T00:00:00.000Z', + hasInsertedMetadataCollEntry: true, + })), + shards: [], + defaultConnectionOptions: {}, + users: [], + ...(logDir + ? { options: { topology: 'standalone', tmpDir: '/tmp', logDir } } + : {}), + } as MongoClusterSerializedProperties; +} + +describe('logs', function () { + let logDir: string; + + beforeEach(async function () { + logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-logs-')); + }); + + afterEach(async function () { + await fs.rm(logDir, { recursive: true, force: true }); + }); + + describe('tailFile', function () { + it('returns the last lines of a file', async function () { + const filePath = path.join(logDir, 'mongod.log'); + await fs.writeFile(filePath, 'one\ntwo\nthree\n'); + + expect(await tailFile(filePath, 2)).to.deep.equal(['two', 'three']); + }); + + it('handles files without a trailing newline', async function () { + const filePath = path.join(logDir, 'mongod.log'); + await fs.writeFile(filePath, 'one\ntwo'); + + expect(await tailFile(filePath, 5)).to.deep.equal(['one', 'two']); + }); + }); + + describe('readClusterLogs', function () { + it('rejects clusters started without a log directory', async function () { + await assert.rejects( + readClusterLogs(cluster({ servers: [{ uuid: 'a', port: 27017 }] }), { + tailLines: 10, + }), + /started without a log directory/, + ); + }); + + it('finds the log file by server uuid and binary', async function () { + const logFile = path.join(logDir, 'mongod-a-2026-01-02T000000000Z.log'); + await fs.writeFile(logFile, 'line 1\nline 2\n'); + + const result = await readClusterLogs( + cluster({ logDir, servers: [{ uuid: 'a', port: 27017 }] }), + { tailLines: 1 }, + ); + + expect(result).to.deep.equal([ + { + host: '127.0.0.1', + port: 27017, + type: 'mongod', + path: logFile, + lines: ['line 2'], + }, + ]); + }); + + it('reports servers without a log file instead of failing', async function () { + const result = await readClusterLogs( + cluster({ + logDir, + servers: [{ uuid: 'missing', port: 27017, isMongos: true }], + }), + { tailLines: 10 }, + ); + + expect(result).to.deep.equal([ + { + host: '127.0.0.1', + port: 27017, + type: 'mongos', + path: null, + lines: [], + }, + ]); + }); + }); +}); diff --git a/packages/devtools-mcp-server/src/logs.ts b/packages/devtools-mcp-server/src/logs.ts new file mode 100644 index 00000000..2bc77d37 --- /dev/null +++ b/packages/devtools-mcp-server/src/logs.ts @@ -0,0 +1,101 @@ +import path from 'path'; +import { promises as fs } from 'fs'; +import type { + MongoClusterSerializedProperties, + MongoServerSerializedProperties, +} from '@mongodb-js/mongodb-runner'; +import { flattenServers } from './registry'; + +/** Reading more than this from the end of a log file is not worth it. */ +const MAX_TAIL_BYTES = 1_048_576; + +export const DEFAULT_LOG_TAIL_LINES = 100; + +export type ServerLog = { + host: string; + port?: number; + type: 'mongod' | 'mongos'; + /** Log file path, or null when the server produced no log file. */ + path: string | null; + lines: string[]; +}; + +export function clusterLogDir( + cluster: MongoClusterSerializedProperties, +): string | undefined { + return cluster.options?.logDir; +} + +/** + * mongodb-runner names the log file `--.log` + * and does not record the timestamp, so the uuid prefix is what identifies it. + */ +function findServerLogFile( + logDir: string, + entries: string[], + server: MongoServerSerializedProperties, +): string | undefined { + const binary = server.isMongos ? 'mongos' : 'mongod'; + const matches = entries + .filter( + (name) => + name.startsWith(`${binary}-${server._id}-`) && name.endsWith('.log'), + ) + .sort(); + const latest = matches[matches.length - 1]; + return latest ? path.join(logDir, latest) : undefined; +} + +/** Returns the last `count` lines of a file, reading at most `MAX_TAIL_BYTES`. */ +export async function tailFile( + filePath: string, + count: number, +): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const { size } = await handle.stat(); + const bytes = Math.min(size, MAX_TAIL_BYTES); + const buffer = Buffer.alloc(bytes); + await handle.read(buffer, 0, bytes, size - bytes); + + const lines = buffer.toString('utf8').split(/\r?\n/); + // The first line may be cut in half unless the whole file was read. + if (bytes < size) lines.shift(); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines.slice(-count); + } finally { + await handle.close(); + } +} + +export async function readClusterLogs( + cluster: MongoClusterSerializedProperties, + { tailLines }: { tailLines: number }, +): Promise { + const logDir = clusterLogDir(cluster); + if (!logDir) { + throw new Error( + 'This cluster was started without a log directory, so there are no logs to read.', + ); + } + + const entries = await fs + .readdir(logDir) + .catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') return [] as string[]; + throw err; + }); + + return Promise.all( + flattenServers(cluster).map(async (server) => { + const filePath = findServerLogFile(logDir, entries, server); + return { + host: server.host ?? '127.0.0.1', + port: server.port, + type: server.isMongos ? ('mongos' as const) : ('mongod' as const), + path: filePath ?? null, + lines: filePath ? await tailFile(filePath, tailLines) : [], + }; + }), + ); +} diff --git a/packages/devtools-mcp-server/src/options.spec.ts b/packages/devtools-mcp-server/src/options.spec.ts new file mode 100644 index 00000000..e5635bc8 --- /dev/null +++ b/packages/devtools-mcp-server/src/options.spec.ts @@ -0,0 +1,83 @@ +import os from 'os'; +import { expect } from 'chai'; +import assert from 'assert'; +import type { MongoClusterOptions } from '@mongodb-js/mongodb-runner'; +import { toRunnerOptions } from './options'; + +describe('toRunnerOptions', function () { + it('defaults the temp directory and leaves everything else unset', async function () { + const { options, args, sls } = await toRunnerOptions({ + topology: 'standalone', + }); + + expect(options.topology).to.equal('standalone'); + expect(options.tmpDir).to.equal(os.tmpdir()); + expect(options.version).to.be.undefined; + expect(options.users).to.be.undefined; + expect(args).to.be.undefined; + expect(sls).to.be.undefined; + }); + + it('passes replica set options through', async function () { + const rsMembers: NonNullable = [ + { priority: 1 }, + { tags: { nodeType: 'ANALYTICS' }, priority: 0 }, + ]; + + const { options } = await toRunnerOptions({ + topology: 'replset', + secondaries: 1, + arbiters: 1, + rsMembers, + version: '8.2', + }); + + expect(options).to.include({ + topology: 'replset', + secondaries: 1, + arbiters: 1, + version: '8.2', + }); + expect(options.rsMembers).to.deep.equal(rsMembers); + }); + + it('passes sharded options through', async function () { + const { options } = await toRunnerOptions({ + topology: 'sharded', + shards: [{ secondaries: 0 }, { secondaries: 2, args: ['--shardsvr'] }], + mongosArgs: [[], ['--setParameter', 'enableTestCommands=1']], + }); + + expect(options.shards).to.deep.equal([ + { secondaries: 0 }, + { secondaries: 2, args: ['--shardsvr'] }, + ]); + expect(options.mongosArgs).to.deep.equal([ + [], + ['--setParameter', 'enableTestCommands=1'], + ]); + }); + + it('keeps mongod args out of the cluster options', async function () { + const args = ['--setParameter', 'enableTestCommands=1']; + + const result = await toRunnerOptions({ topology: 'standalone', args }); + + expect(result.args).to.deep.equal(args); + expect(result.options).to.not.have.property('args'); + }); + + it('rejects an empty slsDir', async function () { + await assert.rejects( + toRunnerOptions({ topology: 'replset', slsDir: '' }), + /slsDir must be the path/, + ); + }); + + it('rejects an slsDir without the SLS compose files', async function () { + await assert.rejects( + toRunnerOptions({ topology: 'replset', slsDir: '/nonexistent/sls' }), + /not a disaggregated-storage-capable MongoDB build/, + ); + }); +}); diff --git a/packages/devtools-mcp-server/src/options.ts b/packages/devtools-mcp-server/src/options.ts new file mode 100644 index 00000000..f5da8ea8 --- /dev/null +++ b/packages/devtools-mcp-server/src/options.ts @@ -0,0 +1,67 @@ +import os from 'os'; +import { + createSLSDisaggregatedStorageOptions, + resolveSLSDir, + type MongoClusterOptions, + type SLSMultiCellEnvironment, +} from '@mongodb-js/mongodb-runner'; +import type { StartClusterInput } from './schema'; + +export interface RunnerClusterOptions { + options: MongoClusterOptions; + /** Passed separately: `start()` in mongodb-runner overwrites `options.args` with this value. */ + args?: string[]; + /** Set for DSC clusters so the allocated SLS ports can be reported. */ + sls?: SLSMultiCellEnvironment; +} + +/** + * Translates the tool input into mongodb-runner's `MongoClusterOptions`. + * Topology-specific fields are passed through as-is: mongodb-runner ignores + * options that do not apply to the selected topology. + */ +export async function toRunnerOptions( + input: StartClusterInput, + { defaultLogDir }: { defaultLogDir?: string } = {}, +): Promise { + const options: MongoClusterOptions = { + topology: input.topology, + tmpDir: input.tmpDir ?? os.tmpdir(), + version: input.version, + downloadUrl: input.downloadUrl, + binDir: input.binDir, + downloadDir: input.downloadDir, + logDir: input.logDir ?? defaultLogDir, + docker: input.docker, + host: input.host, + users: input.users, + oidc: input.oidc, + tlsAddClientKey: input.tlsAddClientKey, + requireApiVersion: input.requireApiVersion, + secondaries: input.secondaries, + arbiters: input.arbiters, + rsMembers: input.rsMembers, + shards: input.shards, + mongosArgs: input.mongosArgs, + }; + + let sls: SLSMultiCellEnvironment | undefined; + if (input.slsDir !== undefined) { + if (!input.slsDir) { + throw new Error( + 'slsDir must be the path to a directory containing the SLS docker compose file and its helper files', + ); + } + const { composeFile } = await resolveSLSDir(input.slsDir, { + requireManifest: input.slsImageTag === undefined, + }); + const disaggregatedStorage = await createSLSDisaggregatedStorageOptions({ + composeFile, + imageTag: input.slsImageTag, + }); + options.disaggregatedStorage = disaggregatedStorage; + sls = disaggregatedStorage.sls; + } + + return { options, args: input.args, sls }; +} diff --git a/packages/devtools-mcp-server/src/registry.ts b/packages/devtools-mcp-server/src/registry.ts new file mode 100644 index 00000000..b05b59a3 --- /dev/null +++ b/packages/devtools-mcp-server/src/registry.ts @@ -0,0 +1,56 @@ +import os from 'os'; +import path from 'path'; +import { + instances, + type MongoClusterOptions, + type MongoClusterSerializedProperties, + type MongoServerSerializedProperties, +} from '@mongodb-js/mongodb-runner'; + +export const DEFAULT_RUNNER_DIR = path.join( + os.homedir(), + '.mongodb', + 'runner2', +); + +/** + * Cluster metadata lives where the `mongodb-runner` CLI keeps it by default, so + * clusters started by either one can be inspected and stopped by the other. + */ +export function resolveRunnerDir(): string { + const fromEnv = process.env.MONGODB_RUNNER_RUNNER_DIR; + return fromEnv ? path.resolve(fromEnv) : DEFAULT_RUNNER_DIR; +} + +export type Topology = MongoClusterOptions['topology']; + +export type StoredCluster = { + id: string; + connectionString: string; + filepath: string; + serialized: MongoClusterSerializedProperties; +}; + +export async function findStoredCluster( + runnerDir: string, + clusterId: string, +): Promise { + for await (const instance of instances({ runnerDir })) { + if (instance.id === clusterId) { + return instance; + } + } + throw new Error( + `No running cluster with id "${clusterId}". Use mongodb_list_clusters to see the available ids.`, + ); +} + +/** Every server of a cluster, including the servers of each shard replica set. */ +export function flattenServers( + cluster: MongoClusterSerializedProperties, +): MongoServerSerializedProperties[] { + return [ + ...cluster.servers, + ...cluster.shards.flatMap((shard) => flattenServers(shard)), + ]; +} diff --git a/packages/devtools-mcp-server/src/runner-dir.spec.ts b/packages/devtools-mcp-server/src/runner-dir.spec.ts new file mode 100644 index 00000000..1f396e4c --- /dev/null +++ b/packages/devtools-mcp-server/src/runner-dir.spec.ts @@ -0,0 +1,24 @@ +import { expect } from 'chai'; +import { resolveRunnerDir, DEFAULT_RUNNER_DIR } from './clusters'; + +describe('resolveRunnerDir', function () { + const original = process.env.MONGODB_RUNNER_RUNNER_DIR; + + afterEach(function () { + if (original === undefined) { + delete process.env.MONGODB_RUNNER_RUNNER_DIR; + } else { + process.env.MONGODB_RUNNER_RUNNER_DIR = original; + } + }); + + it("defaults to the mongodb-runner CLI's directory", function () { + delete process.env.MONGODB_RUNNER_RUNNER_DIR; + expect(resolveRunnerDir()).to.equal(DEFAULT_RUNNER_DIR); + }); + + it('honors MONGODB_RUNNER_RUNNER_DIR', function () { + process.env.MONGODB_RUNNER_RUNNER_DIR = '/tmp/custom-runner'; + expect(resolveRunnerDir()).to.equal('/tmp/custom-runner'); + }); +}); diff --git a/packages/devtools-mcp-server/src/schema.ts b/packages/devtools-mcp-server/src/schema.ts new file mode 100644 index 00000000..0a3580f5 --- /dev/null +++ b/packages/devtools-mcp-server/src/schema.ts @@ -0,0 +1,347 @@ +import { z } from 'zod'; + +/** + * A replica set member. Exposing members explicitly is what allows Atlas-style + * analytics nodes: `{ tags: { nodeType: 'ANALYTICS' }, priority: 0 }`. + */ +const rsMemberSchema = z.object({ + tags: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Replica set member tags, e.g. { "nodeType": "ANALYTICS" } for an Atlas-style analytics node.', + ), + priority: z + .number() + .optional() + .describe( + 'Election priority. One member defaults to priority 1 and all others to 0.', + ), + args: z + .array(z.string()) + .optional() + .describe('Extra mongod arguments for this member only.'), + arbiterOnly: z.boolean().optional().describe('Start this member as arbiter.'), +}); + +const shardSchema = z.object({ + secondaries: z + .number() + .int() + .min(0) + .optional() + .describe('Number of secondary nodes in this shard (default 2).'), + arbiters: z + .number() + .int() + .min(0) + .optional() + .describe('Number of arbiter nodes in this shard (default 0).'), + rsMembers: z + .array(rsMemberSchema) + .min(1) + .optional() + .describe( + 'Explicit members for this shard; overrides secondaries/arbiters.', + ), + args: z + .array(z.string()) + .optional() + .describe('Extra mongod arguments for this shard only.'), + version: z + .string() + .optional() + .describe('MongoDB server version for this shard only.'), + binDir: z + .string() + .optional() + .describe('Binary directory for this shard only.'), + docker: z.string().optional().describe('Docker image for this shard only.'), +}); + +const userSchema = z.object({ + username: z.string(), + password: z.string(), + roles: z + .array( + z.union([ + z.string(), + z.object({ role: z.string(), db: z.string().optional() }), + ]), + ) + .describe( + 'Roles to grant, e.g. ["root"] or [{ "role": "readWrite", "db": "test" }].', + ), + customData: z + .record(z.string(), z.unknown()) + .optional() + .describe('Additional user document fields.'), +}); + +export const startClusterShape = { + topology: z + .enum(['standalone', 'replset', 'sharded']) + .describe( + 'standalone: a single mongod. replset: a replica set. sharded: a config server replica set, shard replica sets and a mongos.', + ), + id: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/) + .optional() + .describe( + 'Cluster id to store metadata under. Generated when omitted. Ids are shared with the mongodb-runner CLI.', + ), + version: z + .string() + .optional() + .describe( + "MongoDB server version to download, e.g. '8.2', '7.0.14', '8.x-enterprise' or 'latest-alpha'. Defaults to the downloader's current stable version.", + ), + downloadUrl: z + .string() + .optional() + .describe( + 'Direct URL to a MongoDB tarball. Takes precedence over version and is required for builds that are not published to download.mongodb.org.', + ), + binDir: z + .string() + .optional() + .describe( + 'Directory containing mongod/mongos binaries. Skips downloading.', + ), + downloadDir: z + .string() + .optional() + .describe('Directory where server binaries are downloaded and cached.'), + tmpDir: z + .string() + .optional() + .describe( + 'Directory for temporary files such as dbPath. Defaults to the OS temp directory.', + ), + logDir: z + .string() + .optional() + .describe( + 'Directory to write server log files to. No logs are kept when omitted.', + ), + docker: z + .string() + .optional() + .describe( + 'Docker image to run the server instances under instead of local binaries.', + ), + args: z + .array(z.string()) + .optional() + .describe( + 'Extra mongod arguments, e.g. ["--setParameter", "enableTestCommands=1"]. Ports are allocated automatically unless --port is passed; for sharded clusters these apply to the shard nodes only.', + ), + host: z + .string() + .optional() + .describe( + 'Host the servers bind to and that appears in the connection string. Defaults to 127.0.0.1.', + ), + users: z + .array(userSchema) + .min(1) + .optional() + .describe( + 'User accounts to create after startup. This enables authentication.', + ), + oidc: z + .string() + .optional() + .describe( + 'Start an OIDC mock provider with these arguments and enable MONGODB-OIDC authentication, e.g. \'--payload={"groups":["x"]}\' --expiry=60. Enterprise server versions only; then connect with authMechanism=MONGODB-OIDC.', + ), + tlsAddClientKey: z + .boolean() + .optional() + .describe( + 'Automatically add a TLS client certificate key file to each node when TLS is configured. Disable if you manage certificates yourself.', + ), + requireApiVersion: z + .number() + .int() + .optional() + .describe( + 'Require clients to send this API version. Not supported for replica sets.', + ), + secondaries: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Number of secondary nodes per replica set (default 2, or 1 for DSC).', + ), + arbiters: z + .number() + .int() + .min(0) + .optional() + .describe('Number of arbiter nodes per replica set (default 0).'), + rsMembers: z + .array(rsMemberSchema) + .min(1) + .optional() + .describe( + 'Explicit replica set members; overrides secondaries/arbiters. Use this for analytics nodes or custom priorities.', + ), + shards: z + .union([z.number().int().min(1), z.array(shardSchema).min(1)]) + .optional() + .describe( + 'Number of shards (default 3), or an explicit list of replica set configurations. When a list is given the first entry is the config server and every following entry is a shard.', + ), + mongosArgs: z + .array(z.array(z.string())) + .optional() + .describe( + 'Arguments for each mongos router, one entry per router. Defaults to a single mongos. Each entry is combined with args.', + ), + slsDir: z + .string() + .optional() + .describe( + 'Start a DSC cluster: path to the buildscripts/modules/atlas directory of a disaggregated-storage-capable MongoDB build (or any parent containing it). Requires Docker with compose v2, SLS image access and a DSC-capable mongod via binDir or downloadUrl.', + ), + slsImageTag: z + .string() + .optional() + .describe( + 'SLS docker image tag for DSC clusters. Defaults to the pinned_sls_commit in the manifest.json next to the compose file.', + ), +}; + +export const stopClusterShape = { + id: z.string().min(1).optional().describe('Id of the cluster to stop.'), + all: z + .boolean() + .optional() + .describe( + 'Stop every cluster tracked in the runner directory, including clusters started by the mongodb-runner CLI. Cannot be combined with id.', + ), +}; + +const clusterIdField = z + .string() + .min(1) + .describe( + 'Id of a running cluster, as returned by mongodb_start_cluster or mongodb_list_clusters.', + ); +const databaseField = z.string().min(1).describe('Database name.'); +const collectionField = z.string().min(1).describe('Collection name.'); +const ejsonObject = (description: string) => + z.record(z.string(), z.unknown()).describe(description); + +export const runCommandShape = { + clusterId: clusterIdField, + database: z.string().min(1).describe('Database to run the command against.'), + command: ejsonObject( + 'Database command document, as Extended JSON, e.g. { "distinct": "users", "key": "name" }.', + ), +}; + +export const getClusterShape = { + id: z + .string() + .min(1) + .describe('Id of a running cluster, as returned by mongodb_start_cluster.'), +}; + +export const clusterLogsShape = { + id: z + .string() + .min(1) + .describe('Id of a running cluster, as returned by mongodb_start_cluster.'), + tailLines: z + .number() + .int() + .min(1) + .max(1_000) + .optional() + .describe('Number of trailing lines to read per server (default 100).'), +}; + +export const saveFixtureShape = { + clusterId: clusterIdField, + database: databaseField, + collection: collectionField, + path: z + .string() + .min(1) + .optional() + .describe( + 'File to write. Defaults to .devtools-mcp/fixtures/..ejson in the working directory.', + ), + filter: ejsonObject( + 'Only save documents matching this filter, as Extended JSON.', + ).optional(), + limit: z + .number() + .int() + .min(1) + .optional() + .describe('Maximum documents to save. Defaults to all of them.'), +}; + +export const loadFixtureShape = { + clusterId: clusterIdField, + database: databaseField, + collection: collectionField, + path: z + .string() + .min(1) + .optional() + .describe( + 'File to read. Defaults to .devtools-mcp/fixtures/..ejson in the working directory.', + ), + drop: z + .boolean() + .optional() + .describe('Drop the collection before inserting (default false).'), + ordered: z + .boolean() + .optional() + .describe('Insert in order and stop at the first error (default true).'), +}; + +const failPointModeSchema = z.union([ + z.literal('off'), + z.literal('alwaysOn'), + z.object({ times: z.number().int().min(1) }), + z.object({ activationProbability: z.number().min(0).max(1) }), +]); + +export const failPointShape = { + clusterId: clusterIdField, + name: z.string().min(1).describe('Fail point name, e.g. "failCommand".'), + mode: failPointModeSchema.describe( + 'Fail point mode: "off", "alwaysOn", { "times": n } or { "activationProbability": p }.', + ), + data: ejsonObject('Optional fail point data document.').optional(), +}; + +export const startClusterInputSchema = z.object(startClusterShape); +export const stopClusterInputSchema = z.object(stopClusterShape); +export const runCommandInputSchema = z.object(runCommandShape); +export const getClusterInputSchema = z.object(getClusterShape); +export const clusterLogsInputSchema = z.object(clusterLogsShape); +export const saveFixtureInputSchema = z.object(saveFixtureShape); +export const loadFixtureInputSchema = z.object(loadFixtureShape); +export const failPointInputSchema = z.object(failPointShape); + +export type StartClusterInput = z.infer; +export type StopClusterInput = z.infer; +export type RSMemberInput = z.infer; +export type ShardInput = z.infer; +export type UserInput = z.infer; +export type RunCommandInput = z.infer; +export type GetClusterInput = z.infer; +export type ClusterLogsInput = z.infer; +export type SaveFixtureInput = z.infer; +export type LoadFixtureInput = z.infer; +export type FailPointInput = z.infer; diff --git a/packages/devtools-mcp-server/src/tools/cluster-logs.ts b/packages/devtools-mcp-server/src/tools/cluster-logs.ts new file mode 100644 index 00000000..8599ccc0 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/cluster-logs.ts @@ -0,0 +1,33 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { clusterLogsShape } from '../schema'; +import { jsonResult } from './result'; + +export function registerClusterLogsTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_cluster_logs', + { + title: 'Read cluster logs', + description: `Returns the last lines of every mongod/mongos log file of a cluster, per server. Useful when a test against the cluster failed. + +Clusters are started with a log directory by default (/logs); servers started without one have no logs to read. At most 1 MB is read from the end of each file.`, + inputSchema: clusterLogsShape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input) => { + const logs = await clusters.logs(input); + return jsonResult( + { logs }, + `Read logs for ${logs.length} server(s) of cluster "${input.id}".`, + ); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/fail-point.ts b/packages/devtools-mcp-server/src/tools/fail-point.ts new file mode 100644 index 00000000..33a848f2 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/fail-point.ts @@ -0,0 +1,41 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Database } from '../database'; +import { failPointShape } from '../schema'; +import { jsonResult } from './result'; + +export function registerFailPointTool( + server: McpServer, + database: Database, +): void { + server.registerTool( + 'mongodb_fail_point', + { + title: 'Configure a fail point', + description: `Configures a server fail point, e.g. name "failCommand" with mode "alwaysOn" and data making writes fail. + +The cluster must have been started with test commands enabled, e.g. args ["--setParameter", "enableTestCommands=true"]. Turn the fail point off again with mode "off".`, + inputSchema: failPointShape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input) => { + const result = await database.runCommand({ + clusterId: input.clusterId, + database: 'admin', + command: { + configureFailPoint: input.name, + mode: input.mode, + ...(input.data ? { data: input.data } : {}), + }, + }); + return jsonResult( + { ...result }, + `Fail point "${input.name}" configured on cluster "${input.clusterId}".`, + ); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/fixtures.ts b/packages/devtools-mcp-server/src/tools/fixtures.ts new file mode 100644 index 00000000..fac943e1 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/fixtures.ts @@ -0,0 +1,60 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Database } from '../database'; +import { loadFixtureShape, saveFixtureShape } from '../schema'; +import { jsonResult } from './result'; + +const target = + 'The cluster is resolved from its id in the mongodb-runner registry, so no connection string or credentials are needed.'; + +export function registerFixtureTools( + server: McpServer, + database: Database, +): void { + server.registerTool( + 'mongodb_save_fixture', + { + title: 'Save a fixture', + description: `Writes the documents of a collection to an Extended JSON file so the fixture can be reused later or committed to the repository. ${target} + +Without a path, the file goes to .devtools-mcp/fixtures/..ejson in the working directory.`, + inputSchema: saveFixtureShape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input) => { + const result = await database.saveFixture(input); + return jsonResult( + { ...result }, + `Saved ${result.documentCount} document(s) to ${result.path}.`, + ); + }, + ); + + server.registerTool( + 'mongodb_load_fixture', + { + title: 'Load a fixture', + description: `Inserts the documents of an Extended JSON fixture file into a collection. ${target} + +Without a path, the file is read from .devtools-mcp/fixtures/..ejson in the working directory. Set drop to true to empty the collection first.`, + inputSchema: loadFixtureShape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input) => { + const result = await database.loadFixture(input); + return jsonResult( + { ...result }, + `Inserted ${result.insertedCount} document(s) from ${result.path}${result.dropped ? ' (collection dropped first)' : ''}.`, + ); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/get-cluster.ts b/packages/devtools-mcp-server/src/tools/get-cluster.ts new file mode 100644 index 00000000..59f8a5ff --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/get-cluster.ts @@ -0,0 +1,33 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { getClusterShape } from '../schema'; +import { jsonResult } from './result'; + +export function registerGetClusterTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_get_cluster', + { + title: 'Get cluster status', + description: `Inspects a cluster by id: whether it is reachable, its topology, every server's host and port, the log directory and the mongod version and variant. + +Unlike mongodb_list_clusters, which only reads the runner registry, this connects to the cluster. "running": false means the registry entry is stale; mongodb_prune_stale_clusters removes those entries.`, + inputSchema: getClusterShape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input) => { + const result = await clusters.get(input); + const summary = result.running + ? `Cluster "${result.id}" is running (MongoDB ${result.version ?? 'unknown'}, ${result.variant ?? 'unknown'}).` + : `Cluster "${result.id}" is not reachable: ${result.error ?? 'unknown error'}`; + return jsonResult({ ...result }, summary); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/index.ts b/packages/devtools-mcp-server/src/tools/index.ts new file mode 100644 index 00000000..41acf476 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/index.ts @@ -0,0 +1,28 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import type { Database } from '../database'; +import { registerClusterLogsTool } from './cluster-logs'; +import { registerFailPointTool } from './fail-point'; +import { registerFixtureTools } from './fixtures'; +import { registerGetClusterTool } from './get-cluster'; +import { registerListClustersTool } from './list-clusters'; +import { registerPruneClustersTool } from './prune-clusters'; +import { registerRunCommandTool } from './run-command'; +import { registerStartClusterTool } from './start-cluster'; +import { registerStopClusterTool } from './stop-cluster'; + +export function registerTools( + server: McpServer, + clusters: Clusters, + database: Database, +): void { + registerStartClusterTool(server, clusters); + registerListClustersTool(server, clusters); + registerGetClusterTool(server, clusters); + registerClusterLogsTool(server, clusters); + registerStopClusterTool(server, clusters); + registerPruneClustersTool(server, clusters); + registerRunCommandTool(server, database); + registerFixtureTools(server, database); + registerFailPointTool(server, database); +} diff --git a/packages/devtools-mcp-server/src/tools/list-clusters.ts b/packages/devtools-mcp-server/src/tools/list-clusters.ts new file mode 100644 index 00000000..3541f198 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/list-clusters.ts @@ -0,0 +1,33 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { jsonResult } from './result'; + +export function registerListClustersTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_list_clusters', + { + title: 'List MongoDB clusters', + description: `Lists the MongoDB clusters tracked in the mongodb-runner registry, sorted by start time. The registry is shared with the mongodb-runner CLI, so clusters started by either one are included. + +Each entry contains the cluster id, its connection string and its topology. Entries are read from the registry without contacting the servers: a cluster that crashed can still be listed until it is stopped.`, + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const result = await clusters.list(); + const summary = + result.length === 0 + ? 'No MongoDB clusters are running.' + : `${result.length} running MongoDB cluster(s).`; + return jsonResult({ clusters: result }, summary); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/prune-clusters.ts b/packages/devtools-mcp-server/src/tools/prune-clusters.ts new file mode 100644 index 00000000..ac3b7e55 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/prune-clusters.ts @@ -0,0 +1,31 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { jsonResult } from './result'; + +export function registerPruneClustersTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_prune_stale_clusters', + { + title: 'Prune stale clusters', + description: `Removes registry entries for clusters that are no longer reachable, so mongodb_list_clusters stops reporting them. Only metadata is deleted; nothing is stopped.`, + inputSchema: {}, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const result = await clusters.prune(); + const summary = + result.removed.length === 0 + ? 'No stale clusters found.' + : `Removed ${result.removed.length} stale cluster(s): ${result.removed.join(', ')}`; + return jsonResult({ ...result }, summary); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/result.ts b/packages/devtools-mcp-server/src/tools/result.ts new file mode 100644 index 00000000..30c19475 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/result.ts @@ -0,0 +1,16 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export function jsonResult>( + value: T, + summary: string, +): CallToolResult { + return { + content: [ + { + type: 'text', + text: `${summary}\n\n${JSON.stringify(value, null, 2)}`, + }, + ], + structuredContent: value, + }; +} diff --git a/packages/devtools-mcp-server/src/tools/run-command.ts b/packages/devtools-mcp-server/src/tools/run-command.ts new file mode 100644 index 00000000..498ca20a --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/run-command.ts @@ -0,0 +1,37 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Database } from '../database'; +import { runCommandShape } from '../schema'; +import { jsonResult } from './result'; + +const target = + 'The cluster is resolved from its id in the mongodb-runner registry, so no connection string or credentials are needed.'; + +const ejson = + 'The command document is Extended JSON: plain JSON works for standard values, and {"$oid": ...} or {"$date": ...} are accepted for BSON types. Results are returned as canonical Extended JSON.'; + +export function registerRunCommandTool( + server: McpServer, + database: Database, +): void { + server.registerTool( + 'mongodb_run_command', + { + title: 'Run a database command', + description: `Runs an arbitrary database command against a cluster, for operations the MongoDB MCP server tools do not cover (fail points, collMod, distinct, ...). Commands can change data or server configuration. ${target} ${ejson}`, + inputSchema: runCommandShape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input) => { + const result = await database.runCommand(input); + return jsonResult( + { ...result }, + `Command executed on database "${input.database}".`, + ); + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/start-cluster.ts b/packages/devtools-mcp-server/src/tools/start-cluster.ts new file mode 100644 index 00000000..18b7ce57 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/start-cluster.ts @@ -0,0 +1,55 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { startClusterShape } from '../schema'; +import { jsonResult } from './result'; +import { createStartProgress } from './start-progress'; + +const description = `Starts a MongoDB deployment on the local machine with mongodb-runner and returns its id and connection string. + +The deployment keeps running after this tool returns and independently of this MCP server; stop it with mongodb_stop_cluster. Server ports are allocated automatically unless --port is passed in args. The first start for a given version downloads the MongoDB binaries, which can take several minutes. + +Supported deployments: +- standalone: a single mongod. +- replset: a replica set. Use secondaries/arbiters for a homogeneous set, or rsMembers for full control (analytics nodes are members with tags { "nodeType": "ANALYTICS" } and priority 0). +- sharded: a config server replica set, shard replica sets and mongos routers. shards takes a count or a per-shard configuration list; mongosArgs configures each router. +- DSC: pass slsDir (and a disaggregated-storage-capable build via binDir or downloadUrl) to back the cluster with the SLS storage layer. Requires Docker with compose v2. + +Options that do not apply to the selected topology are ignored. + +Progress notifications are sent while the cluster starts when the client supplies a progress token with the request.`; + +export function registerStartClusterTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_start_cluster', + { + title: 'Start a MongoDB cluster', + description, + inputSchema: startClusterShape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input, extra) => { + const progress = createStartProgress(extra); + try { + await progress.report( + `Starting ${input.topology} cluster. This can take several minutes if the MongoDB binaries need to be downloaded.`, + ); + const result = await clusters.start(input); + await progress.report(`Cluster "${result.id}" is ready.`); + return jsonResult( + { ...result }, + `Started ${result.topology} cluster "${result.id}". Connection string: ${result.connectionString}`, + ); + } finally { + progress.stop(); + } + }, + ); +} diff --git a/packages/devtools-mcp-server/src/tools/start-progress.spec.ts b/packages/devtools-mcp-server/src/tools/start-progress.spec.ts new file mode 100644 index 00000000..0c68eec9 --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/start-progress.spec.ts @@ -0,0 +1,88 @@ +import { expect } from 'chai'; +import type { ServerNotification } from '@modelcontextprotocol/sdk/types.js'; +import { createStartProgress, type ProgressContext } from './start-progress'; + +function createFakeContext(progressToken?: string | number): { + context: ProgressContext; + notifications: ServerNotification[]; +} { + const notifications: ServerNotification[] = []; + return { + notifications, + context: { + _meta: progressToken === undefined ? {} : { progressToken }, + sendNotification: async (notification) => { + notifications.push(notification); + }, + }, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('createStartProgress', function () { + it('does nothing when the client did not send a progress token', async function () { + const { context, notifications } = createFakeContext(); + const progress = createStartProgress(context, { heartbeatIntervalMs: 1 }); + + await progress.report('Starting'); + await sleep(10); + progress.stop(); + + expect(notifications).to.be.empty; + }); + + it('reports numbered progress updates carrying the token', async function () { + const { context, notifications } = createFakeContext('token-1'); + const progress = createStartProgress(context, { + heartbeatIntervalMs: 60_000, + }); + + await progress.report('Starting'); + await progress.report('Ready'); + progress.stop(); + + expect(notifications).to.deep.equal([ + { + method: 'notifications/progress', + params: { progressToken: 'token-1', progress: 1, message: 'Starting' }, + }, + { + method: 'notifications/progress', + params: { progressToken: 'token-1', progress: 2, message: 'Ready' }, + }, + ]); + }); + + it('keeps reporting elapsed time until stopped', async function () { + const { context, notifications } = createFakeContext(7); + const progress = createStartProgress(context, { + heartbeatIntervalMs: 5, + startedAt: Date.now() - 3000, + }); + + await sleep(30); + progress.stop(); + const afterStop = notifications.length; + + expect(afterStop).to.be.greaterThan(1); + + const first = notifications[0] as { + params: { + progressToken: string | number; + progress: number; + message?: string; + }; + }; + expect(first.params.progressToken).to.equal(7); + expect(first.params.progress).to.equal(1); + expect(first.params.message).to.match( + /^Still starting the cluster \(\d+s elapsed\)\.$/, + ); + + await sleep(30); + expect(notifications).to.have.lengthOf(afterStop); + }); +}); diff --git a/packages/devtools-mcp-server/src/tools/start-progress.ts b/packages/devtools-mcp-server/src/tools/start-progress.ts new file mode 100644 index 00000000..58c37e2b --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/start-progress.ts @@ -0,0 +1,74 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ServerNotification, + ServerRequest, +} from '@modelcontextprotocol/sdk/types.js'; + +/** + * The parts of the request context progress reporting needs. Narrower than the + * SDK's `RequestHandlerExtra` so tests can provide a plain object. + */ +export type ProgressContext = Pick< + RequestHandlerExtra, + '_meta' | 'sendNotification' +>; + +export const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; + +export interface StartProgress { + /** Sends a progress notification for the current tool call, if the client asked for one. */ + report(message: string): Promise; + /** Stops periodic updates. */ + stop(): void; +} + +/** + * MCP progress is opt-in: the client sends `_meta.progressToken` with the + * request, and every `notifications/progress` must carry it back. When no token + * is present all of this is a no-op. + */ +export function createStartProgress( + context: ProgressContext, + { + startedAt = Date.now(), + heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, + }: { startedAt?: number; heartbeatIntervalMs?: number } = {}, +): StartProgress { + const progressToken = context._meta?.progressToken; + if (progressToken === undefined) { + return { report: () => Promise.resolve(), stop: () => undefined }; + } + + let progress = 0; + // Notifications are chained so that progress values arrive in order. + let pending = Promise.resolve(); + + // An arrow function (unlike a hoisted declaration) keeps the narrowing of + // `progressToken` from the early return above. + const report = (message: string): Promise => { + progress += 1; + pending = pending + .then(() => + context.sendNotification({ + method: 'notifications/progress', + params: { progressToken, progress, message }, + }), + ) + // A disconnected client must not turn a starting cluster into a failed tool call. + .catch(() => undefined); + return pending; + }; + + const heartbeat = setInterval(() => { + const elapsed = Math.round((Date.now() - startedAt) / 1000); + void report(`Still starting the cluster (${elapsed}s elapsed).`); + }, heartbeatIntervalMs); + heartbeat.unref(); + + return { + report, + stop() { + clearInterval(heartbeat); + }, + }; +} diff --git a/packages/devtools-mcp-server/src/tools/stop-cluster.ts b/packages/devtools-mcp-server/src/tools/stop-cluster.ts new file mode 100644 index 00000000..fcc66fcf --- /dev/null +++ b/packages/devtools-mcp-server/src/tools/stop-cluster.ts @@ -0,0 +1,34 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Clusters } from '../clusters'; +import { stopClusterShape } from '../schema'; +import { jsonResult } from './result'; + +export function registerStopClusterTool( + server: McpServer, + clusters: Clusters, +): void { + server.registerTool( + 'mongodb_stop_cluster', + { + title: 'Stop MongoDB clusters', + description: `Stops a MongoDB cluster by id, or every cluster in the mongodb-runner registry when all is true. + +The registry is shared with the mongodb-runner CLI, so all: true also stops clusters started outside this server. Pass id (from mongodb_start_cluster or mongodb_list_clusters) to stop a single cluster.`, + inputSchema: stopClusterShape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input) => { + const result = await clusters.stop(input); + const summary = + result.stopped.length === 0 + ? 'No clusters were running.' + : `Stopped ${result.stopped.length} cluster(s): ${result.stopped.join(', ')}`; + return jsonResult({ ...result }, summary); + }, + ); +} diff --git a/packages/devtools-mcp-server/test/opencode-plugin.spec.mjs b/packages/devtools-mcp-server/test/opencode-plugin.spec.mjs new file mode 100644 index 00000000..16f65da0 --- /dev/null +++ b/packages/devtools-mcp-server/test/opencode-plugin.spec.mjs @@ -0,0 +1,335 @@ +import { expect } from 'chai'; +import plugin, { + CHROME_DEVTOOLS_SERVER_PACKAGE, + loadSkills, + mcpServerConfig, + MONGODB_SERVER_PACKAGE, + remoteServerConfig, + resolveChromeDevtoolsServerCommand, + resolveMongodbServerCommand, + resolveRemoteServer, + resolveServerCommand, + SERVER_PACKAGE, +} from '../opencode-plugin.mjs'; + +function createFakeContext(options = {}, existing = {}) { + const registered = []; + const skills = []; + const context = { + options, + mcp: { + transform: async (callback) => { + callback({ + get(name) { + return existing[name]; + }, + set(name, config) { + registered.push({ name, config }); + }, + }); + }, + }, + skill: { + transform: async (callback) => { + callback({ + add(skill) { + skills.push(skill); + }, + }); + }, + }, + }; + + return { context, registered, skills }; +} + +describe('opencode plugin', function () { + const savedEnv = { + DEVPROD_MCP_URL: process.env.DEVPROD_MCP_URL, + GLEAN_ENTERPRISE_URL: process.env.GLEAN_ENTERPRISE_URL, + }; + + beforeEach(function () { + delete process.env.DEVPROD_MCP_URL; + delete process.env.GLEAN_ENTERPRISE_URL; + }); + + afterEach(function () { + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it('has a stable id', function () { + expect(plugin.id).to.equal('devtools'); + }); + + it('registers the devtools, mongodb and chrome-devtools MCP servers', async function () { + const { context, registered } = createFakeContext(); + + await plugin.setup(context); + + expect(registered).to.have.lengthOf(3); + expect(registered[0]).to.deep.include({ name: 'devtools' }); + expect(registered[1]).to.deep.equal({ + name: 'mongodb', + config: { + type: 'local', + command: ['npx', '-y', `${MONGODB_SERVER_PACKAGE}@3`], + }, + }); + expect(registered[2]).to.deep.equal({ + name: 'chrome-devtools', + config: { + type: 'local', + command: ['npx', '-y', `${CHROME_DEVTOOLS_SERVER_PACKAGE}@1`], + }, + }); + }); + + it('does not replace servers the user already configured', async function () { + const existingChrome = { + type: 'local', + command: ['chrome-devtools-mcp', '--browserUrl', 'http://127.0.0.1:9222'], + }; + const { context, registered } = createFakeContext( + {}, + { 'chrome-devtools': existingChrome }, + ); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.deep.equal([ + 'devtools', + 'mongodb', + ]); + expect(registered[1].config.command).to.deep.equal([ + 'npx', + '-y', + `${MONGODB_SERVER_PACKAGE}@3`, + ]); + }); + + it('registers the internal servers when their URLs are provided', async function () { + const { context, registered } = createFakeContext({ + devprodMcpUrl: 'https://devprod.example.com/mcp', + gleanEnterpriseUrl: 'https://glean.example.com/mcp/endpoint', + }); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.deep.equal([ + 'devtools', + 'mongodb', + 'chrome-devtools', + 'devprod-mcp', + 'glean-enterprise', + ]); + expect(registered[3].config).to.deep.equal({ + type: 'remote', + url: 'https://devprod.example.com/mcp', + }); + expect(registered[4].config).to.deep.equal({ + type: 'remote', + url: 'https://glean.example.com/mcp/endpoint', + }); + }); + + it('reads the internal server URLs from the environment', async function () { + process.env.DEVPROD_MCP_URL = 'https://devprod.example.com/mcp'; + process.env.GLEAN_ENTERPRISE_URL = 'https://glean.example.com/mcp'; + + const { context, registered } = createFakeContext(); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.deep.equal([ + 'devtools', + 'mongodb', + 'chrome-devtools', + 'devprod-mcp', + 'glean-enterprise', + ]); + expect(registered[3].config).to.deep.equal({ + type: 'remote', + url: 'https://devprod.example.com/mcp', + }); + expect(registered[4].config).to.deep.equal({ + type: 'remote', + url: 'https://glean.example.com/mcp', + }); + }); + + it('prefers plugin options over the environment', function () { + expect( + resolveRemoteServer( + { devprodMcpUrl: 'https://from-option.example.com/mcp' }, + 'devprodMcpUrl', + 'DEVPROD_MCP_URL', + { DEVPROD_MCP_URL: 'https://from-env.example.com/mcp' }, + ), + ).to.deep.equal({ + type: 'remote', + url: 'https://from-option.example.com/mcp', + }); + }); + + it('does not register internal servers without URLs', async function () { + const { context, registered } = createFakeContext(); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.not.include.members([ + 'devprod-mcp', + 'glean-enterprise', + ]); + }); + + it('ignores invalid or non-http URLs', function () { + expect(remoteServerConfig(undefined)).to.be.undefined; + expect(remoteServerConfig('')).to.be.undefined; + expect(remoteServerConfig('not a url')).to.be.undefined; + expect(remoteServerConfig('ftp://example.com/mcp')).to.be.undefined; + expect(remoteServerConfig('https://example.com/mcp')).to.deep.equal({ + type: 'remote', + url: 'https://example.com/mcp', + }); + }); + + it('registers the skills shipped with the package', async function () { + const { context, skills } = createFakeContext(); + + await plugin.setup(context); + + expect(skills.map((skill) => skill.id)).to.have.members([ + 'start-compass-with-devtools', + 'devtools-mcp-workflows', + ]); + const compass = skills.find( + (skill) => skill.id === 'start-compass-with-devtools', + ); + expect(compass.name).to.equal('Start Compass with DevTools'); + expect(compass.description).to.match(/Compass/); + expect(compass.content).to.include('# Start Compass with DevTools'); + expect(compass.content).to.not.include('---'); + expect(compass.path).to.match( + /skills[/\\]start-compass-with-devtools[/\\]SKILL\.md$/, + ); + }); + + it('can leave the official servers out', async function () { + const { context, registered, skills } = createFakeContext({ + mongodbServerCommand: false, + chromeDevtoolsServerCommand: false, + }); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.deep.equal(['devtools']); + expect(skills).to.have.lengthOf(2); + }); + + it('can leave the chrome-devtools server out on its own', async function () { + const { context, registered } = createFakeContext({ + chromeDevtoolsServerCommand: false, + }); + + await plugin.setup(context); + + expect(registered.map((entry) => entry.name)).to.deep.equal([ + 'devtools', + 'mongodb', + ]); + }); + + it('accepts a chrome-devtools command override for Compass', function () { + expect( + resolveChromeDevtoolsServerCommand({ + chromeDevtoolsServerCommand: [ + 'npx', + '-y', + 'chrome-devtools-mcp@1', + '--browser-url', + 'http://127.0.0.1:9222', + ], + }), + ).to.deep.equal([ + 'npx', + '-y', + 'chrome-devtools-mcp@1', + '--browser-url', + 'http://127.0.0.1:9222', + ]); + }); + + it('loads every bundled skill file', function () { + expect(loadSkills().map((skill) => skill.id)).to.deep.equal([ + 'start-compass-with-devtools', + 'devtools-mcp-workflows', + ]); + }); + + it('accepts an official server command override', function () { + expect( + resolveMongodbServerCommand({ + mongodbServerCommand: ['npx', '-y', 'mongodb-mcp-server@3.3.0'], + }), + ).to.deep.equal(['npx', '-y', 'mongodb-mcp-server@3.3.0']); + }); + + it('runs the binary shipped with the package when it exists', function () { + expect( + resolveServerCommand( + {}, + { ownBinPath: '/package/dist/bin.js', exists: () => true }, + ), + ).to.deep.equal(['node', '/package/dist/bin.js']); + }); + + it('falls back to npx when the package is not built', function () { + expect( + resolveServerCommand( + {}, + { ownBinPath: '/package/dist/bin.js', exists: () => false }, + ), + ).to.deep.equal(['npx', '-y', SERVER_PACKAGE]); + }); + + it('accepts a devtools server command override', async function () { + const { context, registered } = createFakeContext({ + serverCommand: [ + 'node', + '/checkout/packages/devtools-mcp-server/dist/bin.js', + ], + }); + + await plugin.setup(context); + + expect(registered[0].config.command).to.deep.equal([ + 'node', + '/checkout/packages/devtools-mcp-server/dist/bin.js', + ]); + }); + + it('ignores invalid server command overrides', function () { + expect( + resolveServerCommand( + { serverCommand: [] }, + { ownBinPath: '/x', exists: () => false }, + ), + ).to.deep.equal(['npx', '-y', SERVER_PACKAGE]); + expect( + resolveServerCommand( + { serverCommand: [42] }, + { ownBinPath: '/x', exists: () => false }, + ), + ).to.deep.equal(['npx', '-y', SERVER_PACKAGE]); + }); + + it('builds typed local server configs', function () { + expect( + mcpServerConfig({ serverCommand: ['node', '/x/bin.js'] }), + ).to.deep.equal({ type: 'local', command: ['node', '/x/bin.js'] }); + }); +}); diff --git a/packages/devtools-mcp-server/tsconfig-lint.json b/packages/devtools-mcp-server/tsconfig-lint.json new file mode 100644 index 00000000..6bdef84f --- /dev/null +++ b/packages/devtools-mcp-server/tsconfig-lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/devtools-mcp-server/tsconfig.json b/packages/devtools-mcp-server/tsconfig.json new file mode 100644 index 00000000..20dcc83e --- /dev/null +++ b/packages/devtools-mcp-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@mongodb-js/tsconfig-devtools/tsconfig.common.json", + "compilerOptions": { + "module": "node16", + "moduleResolution": "node16" + } +}