diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 782a0ad75..05b63ec6b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,27 @@ updates: directory: "/" schedule: interval: "weekly" + ignore: + # We are ignoring major updates on yargs-parser because yargs-parser@22 + # does not play nicely when bundled using webpack. Our VSCode extension + # bundles MCP server with the extension code and yargs-parser from MCP + # server ends up on the final bundle which leads to issues such as - + # https://github.com/mongodb-js/vscode/issues/1149. + # + # This was reported to yargs-parser as well - + # https://github.com/yargs/yargs-parser/issues/517 and we already tried + # their suggestion about disabling the meta resolution in webpack, + # alongside others (dependency overrides, disabling the bundling of + # yargs-parser), and none of the solutions yield a working extension. So + # until we figure out a fix for this we need to keep mongodb-mcp-server + # working with v21 of yargs-parser. + - dependency-name: "yargs-parser" + update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/code-health-fork.yml b/.github/workflows/code-health-fork.yml index f5ef5f763..a07b0d902 100644 --- a/.github/workflows/code-health-fork.yml +++ b/.github/workflows/code-health-fork.yml @@ -20,6 +20,11 @@ jobs: - uses: GitHubSecurityLab/actions-permissions/monitor@v1 if: matrix.os == 'ubuntu-latest' - uses: actions/checkout@v5 + - uses: docker/setup-docker-action@v4 + if: matrix.os == 'ubuntu-latest' + name: Setup Docker Environment + with: + set-host: true - uses: actions/setup-node@v5 with: node-version-file: package.json diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index 2dfba473d..fc8b4b153 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -21,6 +21,11 @@ jobs: - uses: GitHubSecurityLab/actions-permissions/monitor@v1 if: matrix.os == 'ubuntu-latest' - uses: actions/checkout@v5 + - uses: docker/setup-docker-action@v4 + if: matrix.os == 'ubuntu-latest' + name: Setup Docker Environment + with: + set-host: true - uses: actions/setup-node@v5 with: node-version-file: package.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f4f0237e..40a0b0a9c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,10 +25,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/jira-issue.yml b/.github/workflows/jira-issue.yml index 3760b59c4..f66431ae0 100644 --- a/.github/workflows/jira-issue.yml +++ b/.github/workflows/jira-issue.yml @@ -54,7 +54,7 @@ jobs: - name: Add comment if: steps.create.outputs.issue-key - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: issue-number: ${{ github.event.issue.number }} body: | @@ -104,7 +104,7 @@ jobs: transition-id: 61 - name: Add closure comment if: steps.close_jira_ticket.outcome == 'success' - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/Dockerfile b/Dockerfile index d842f6333..6d692a4dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM node:24-alpine ARG VERSION=latest RUN addgroup -S mcp && adduser -S mcp -G mcp RUN npm install -g mongodb-mcp-server@${VERSION} diff --git a/README.md b/README.md index f8ea9afd6..092e5f276 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow | `exportTimeoutMs` | `MDB_MCP_EXPORT_TIMEOUT_MS` | 300000 | Time in milliseconds after which an export is considered expired and eligible for cleanup. | | `exportCleanupIntervalMs` | `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` | 120000 | Time in milliseconds between export cleanup cycles that remove expired export files. | | `atlasTemporaryDatabaseUserLifetimeMs` | `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` | 14400000 | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. | +| `voyageApiKey` | `MDB_VOYAGE_API_KEY` | | API key for communicating with Voyage AI. Used for generating embeddings for Vector search. | #### Logger Options @@ -417,7 +418,7 @@ Operation types: - `update` - Tools that update resources, such as update document, rename collection, etc. - `delete` - Tools that delete resources, such as delete document, drop collection, etc. - `read` - Tools that read resources, such as find, aggregate, list clusters, etc. -- `metadata` - Tools that read metadata, such as list databases, list collections, collection schema, etc. +- `metadata` - Tools that read metadata, such as list databases/collections/indexes, infer collection schema, etc. - `connect` - Tools that allow you to connect or switch the connection to a MongoDB instance. If this is disabled, you will need to provide a connection string through the config when starting the server. #### Require Confirmation diff --git a/package-lock.json b/package-lock.json index c52a96fc9..6b7ee0e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-mcp-server", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-mcp-server", - "version": "1.0.2", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", @@ -26,7 +26,7 @@ "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", "ts-levenshtein": "^1.0.7", - "yargs-parser": "^22.0.0", + "yargs-parser": "21.1.1", "zod": "^3.25.76" }, "bin": { @@ -64,6 +64,7 @@ "proper-lockfile": "^4.1.2", "semver": "^7.7.2", "simple-git": "^3.28.0", + "testcontainers": "^11.7.1", "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.41.0", @@ -1072,6 +1073,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -1833,6 +1841,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", + "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1998,6 +2058,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", @@ -2144,9 +2215,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.0.tgz", - "integrity": "sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", + "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -5296,6 +5367,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.44", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.44.tgz", + "integrity": "sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5350,13 +5444,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/proper-lockfile": { @@ -5420,6 +5514,43 @@ "@types/send": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz", + "integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.129", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", + "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stylis": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", @@ -5506,16 +5637,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", - "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz", + "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", "debug": "^4.3.4" }, "engines": { @@ -5530,6 +5661,163 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz", + "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.0", + "@typescript-eslint/types": "^8.46.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz", + "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz", + "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz", + "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz", + "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.0", + "@typescript-eslint/tsconfig-utils": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz", + "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/project-service": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", @@ -6117,6 +6405,181 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/archiver-utils/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -6224,6 +6687,13 @@ "js-tokens": "^9.0.1" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6233,6 +6703,13 @@ "node": ">= 0.4" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6268,6 +6745,95 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-fs": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.5.tgz", + "integrity": "sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.2.2.tgz", + "integrity": "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6546,6 +7112,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6922,6 +7498,36 @@ "node": ">=18" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7056,6 +7662,33 @@ "node": ">=10.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -7167,9 +7800,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7519,6 +8152,83 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/docker-compose": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.3.0.tgz", + "integrity": "sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", + "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/dompurify": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", @@ -8152,6 +8862,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8263,6 +8983,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8732,7 +9459,20 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6" + } + }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-proto": { @@ -10088,6 +10828,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10128,6 +10921,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10455,8 +11262,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/mobx": { "version": "6.13.7", @@ -10690,9 +11497,9 @@ "optional": true }, "node_modules/mongodb-redact": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mongodb-redact/-/mongodb-redact-1.2.0.tgz", - "integrity": "sha512-UVJBlVNEF/8UhZ/SwR+KJXqf6pVY0b0M9aBa+1cwdRAoFFqH5NZUhMdzaXCCvhY2hoPtZ32Z7vYMoDl6Msmm/g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/mongodb-redact/-/mongodb-redact-1.2.1.tgz", + "integrity": "sha512-waZV5KuNXSihjIu3mgewjAxhOejDRq7W4CEbd9eb5abpKIKxP4sZm29tOaxVoCsNYhicYm4Aw9aHNERCW8uIyQ==", "license": "Apache-2.0", "dependencies": { "regexp.escape": "^2.0.1" @@ -11279,16 +12086,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/openapi-typescript/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/openid-client": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.7.1.tgz", @@ -11916,6 +12713,23 @@ "dev": true, "license": "ISC" }, + "node_modules/properties-reader": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", + "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -11958,8 +12772,8 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -12234,6 +13048,39 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -13340,12 +14187,41 @@ "rxjs": "^7.8.1" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -13418,6 +14294,18 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13847,11 +14735,11 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -13863,8 +14751,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -13875,6 +14763,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -13890,7 +14779,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -13900,15 +14788,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/tar-fs/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -13922,8 +14810,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -14106,6 +14994,107 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/testcontainers": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.7.1.tgz", + "integrity": "sha512-fjut+07G4Avp6Lly/6hQePpUpQFv9ZyQd+7JC5iCDKg+dWa2Sw7fXD3pBrkzslYFfKqGx9M6kyIaLpg9VeMsjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.44", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.3.0", + "dockerode": "^4.0.8", + "get-port": "^7.1.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^2.3.0", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.1", + "tmp": "^0.2.5", + "undici": "^7.16.0" + } + }, + "node_modules/testcontainers/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/testcontainers/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/testcontainers/node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -14218,6 +15207,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-buffer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", @@ -14544,6 +15543,31 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", + "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -14623,9 +15647,9 @@ } }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "dev": true, "license": "MIT" }, @@ -15365,8 +16389,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15401,12 +16423,12 @@ } }, "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yargs/node_modules/ansi-regex": { @@ -15454,16 +16476,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", @@ -15498,6 +16510,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 72981fc6f..659d67283 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mongodb-mcp-server", "description": "MongoDB Model Context Protocol Server", - "version": "1.0.2", + "version": "1.1.0", "type": "module", "exports": { ".": { @@ -74,6 +74,7 @@ "@types/yargs-parser": "^21.0.3", "@typescript-eslint/parser": "^8.44.0", "@vitest/coverage-v8": "^3.2.4", + "@vitest/eslint-plugin": "^1.3.4", "ai": "^4.3.17", "duplexpair": "^1.0.2", "eslint": "^9.34.0", @@ -90,10 +91,10 @@ "proper-lockfile": "^4.1.2", "semver": "^7.7.2", "simple-git": "^3.28.0", + "testcontainers": "^11.7.1", "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.41.0", - "@vitest/eslint-plugin": "^1.3.4", "uuid": "^13.0.0", "vitest": "^3.2.4" }, @@ -115,7 +116,7 @@ "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", "ts-levenshtein": "^1.0.7", - "yargs-parser": "^22.0.0", + "yargs-parser": "21.1.1", "zod": "^3.25.76" }, "engines": { diff --git a/scripts/accuracy/generateTestSummary.ts b/scripts/accuracy/generateTestSummary.ts index 0d76cc3b4..eae58007b 100644 --- a/scripts/accuracy/generateTestSummary.ts +++ b/scripts/accuracy/generateTestSummary.ts @@ -73,7 +73,8 @@ function formatToolCallsWithTooltip(toolCalls: ExpectedToolCall[] | LLMToolCall[ return toolCalls .map((call) => { const params = JSON.stringify(call.parameters, null, 2); - return `${call.toolName}`; + const isOptional = "optional" in call && call.optional; + return `${isOptional ? "(" : ""}${call.toolName}${isOptional ? ")" : ""}`; }) .join(", "); } diff --git a/scripts/accuracy/runAccuracyTests.sh b/scripts/accuracy/runAccuracyTests.sh index 180bd96fb..0b5aa015c 100644 --- a/scripts/accuracy/runAccuracyTests.sh +++ b/scripts/accuracy/runAccuracyTests.sh @@ -8,6 +8,11 @@ export MDB_ACCURACY_RUN_ID=$(npx uuid v4) # export MDB_AZURE_OPEN_AI_API_KEY="" # export MDB_AZURE_OPEN_AI_API_URL="" +# For providing Atlas API credentials (required for Atlas tools) +# Set dummy values for testing (allows Atlas tools to be registered for mocking) +export MDB_MCP_API_CLIENT_ID=${MDB_MCP_API_CLIENT_ID:-"test-atlas-client-id"} +export MDB_MCP_API_CLIENT_SECRET=${MDB_MCP_API_CLIENT_SECRET:-"test-atlas-client-secret"} + # For providing a mongodb based storage to store accuracy result # export MDB_ACCURACY_MDB_URL="" # export MDB_ACCURACY_MDB_DB="" diff --git a/scripts/cleanupAtlasTestLeftovers.test.ts b/scripts/cleanupAtlasTestLeftovers.test.ts index 24351c8b6..7e195d5e2 100644 --- a/scripts/cleanupAtlasTestLeftovers.test.ts +++ b/scripts/cleanupAtlasTestLeftovers.test.ts @@ -4,11 +4,11 @@ import { ConsoleLogger } from "../src/common/logger.js"; import { Keychain } from "../src/lib.js"; import { describe, it } from "vitest"; -function isOlderThanADay(date: string): boolean { - const oneDayInMs = 24 * 60 * 60 * 1000; +function isOlderThanTwoHours(date: string): boolean { + const twoHoursInMs = 2 * 60 * 60 * 1000; const projectDate = new Date(date); const currentDate = new Date(); - return currentDate.getTime() - projectDate.getTime() > oneDayInMs; + return currentDate.getTime() - projectDate.getTime() > twoHoursInMs; } async function findTestOrganization(client: ApiClient): Promise { @@ -32,10 +32,12 @@ async function findAllTestProjects(client: ApiClient, orgId: string): Promise proj.name.startsWith("testProj-")) || []; - return testProjects.filter((proj) => isOlderThanADay(proj.created)); + return testProjects.filter((proj) => isOlderThanTwoHours(proj.created)); } -async function deleteAllClustersOnStaleProject(client: ApiClient, projectId: string): Promise { +async function deleteAllClustersOnStaleProject(client: ApiClient, projectId: string): Promise { + const errors: string[] = []; + const allClusters = await client .listClusters({ params: { @@ -47,10 +49,18 @@ async function deleteAllClustersOnStaleProject(client: ApiClient, projectId: str .then((res) => res.results || []); await Promise.allSettled( - allClusters.map((cluster) => - client.deleteCluster({ params: { path: { groupId: projectId || "", clusterName: cluster.name || "" } } }) - ) + allClusters.map(async (cluster) => { + try { + await client.deleteCluster({ + params: { path: { groupId: projectId || "", clusterName: cluster.name || "" } }, + }); + } catch (error) { + errors.push(`Failed to delete cluster ${cluster.name} in project ${projectId}: ${String(error)}`); + } + }) ); + + return errors; } async function main(): Promise { @@ -66,12 +76,18 @@ async function main(): Promise { ); const testOrg = await findTestOrganization(apiClient); - const testProjects = await findAllTestProjects(apiClient, testOrg.id || ""); + if (!testOrg.id) { + throw new Error("Test organization ID not found."); + } + const testProjects = await findAllTestProjects(apiClient, testOrg.id); if (testProjects.length === 0) { console.log("No stale test projects found for cleanup."); + return; } + const allErrors: string[] = []; + for (const project of testProjects) { console.log(`Cleaning up project: ${project.name} (${project.id})`); if (!project.id) { @@ -79,18 +95,35 @@ async function main(): Promise { continue; } - await deleteAllClustersOnStaleProject(apiClient, project.id); - await apiClient.deleteProject({ - params: { - path: { - groupId: project.id, + // Try to delete all clusters first + const clusterErrors = await deleteAllClustersOnStaleProject(apiClient, project.id); + allErrors.push(...clusterErrors); + + // Try to delete the project + try { + await apiClient.deleteProject({ + params: { + path: { + groupId: project.id, + }, }, - }, - }); - console.log(`Deleted project: ${project.name} (${project.id})`); + }); + console.log(`Deleted project: ${project.name} (${project.id})`); + } catch (error) { + const errorStr = String(error); + const errorMessage = `Failed to delete project ${project.name} (${project.id}): ${errorStr}`; + console.error(errorMessage); + allErrors.push(errorMessage); + } + } + + if (allErrors.length > 0) { + const errorList = allErrors.map((err, i) => `${i + 1}. ${err}`).join("\n"); + const errorSummary = `Cleanup completed with ${allErrors.length} error(s):\n${errorList}`; + throw new Error(errorSummary); } - return; + console.log("All stale test projects cleaned up successfully."); } describe("Cleanup Atlas Test Leftovers", () => { diff --git a/scripts/filter.ts b/scripts/filter.ts index 714214904..d56fb5b0d 100755 --- a/scripts/filter.ts +++ b/scripts/filter.ts @@ -41,6 +41,10 @@ function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document { "deleteProjectIpAccessList", "listOrganizationProjects", "listAlerts", + "listDropIndexes", + "listClusterSuggestedIndexes", + "listSchemaAdvice", + "listSlowQueries", ]; const filteredPaths = {}; diff --git a/src/common/atlas/apiClient.ts b/src/common/atlas/apiClient.ts index 671f0dd64..3d511d3c4 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -429,6 +429,42 @@ export class ApiClient { return data; } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listDropIndexes(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/dropIndexSuggestions", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listSchemaAdvice(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/schemaAdvice", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listClusterSuggestedIndexes(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/suggestedIndexes", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async listDatabaseUsers(options: FetchOptions) { const { data, error, response } = await this.client.GET( @@ -508,6 +544,18 @@ export class ApiClient { return data; } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listSlowQueries(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/processes/{processId}/performanceAdvisor/slowQueryLogs", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async listOrganizations(options?: FetchOptions) { const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs", options); diff --git a/src/common/atlas/cluster.ts b/src/common/atlas/cluster.ts index d0a7f6358..1ea30286b 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -1,7 +1,17 @@ import type { ClusterDescription20240805, FlexClusterDescription20241113 } from "./openapi.js"; import type { ApiClient } from "./apiClient.js"; import { LogId } from "../logger.js"; +import { ConnectionString } from "mongodb-connection-string-url"; +type AtlasProcessId = `${string}:${number}`; + +function extractProcessIds(connectionString: string): Array { + if (!connectionString) { + return []; + } + const connectionStringUrl = new ConnectionString(connectionString); + return connectionStringUrl.hosts as Array; +} export interface Cluster { name?: string; instanceType: "FREE" | "DEDICATED" | "FLEX"; @@ -9,16 +19,19 @@ export interface Cluster { state?: "IDLE" | "CREATING" | "UPDATING" | "DELETING" | "REPAIRING"; mongoDBVersion?: string; connectionString?: string; + processIds?: Array; } export function formatFlexCluster(cluster: FlexClusterDescription20241113): Cluster { + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, instanceType: "FLEX", instanceSize: undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -52,6 +65,7 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { const instanceSize = regionConfigs[0]?.instanceSize ?? "UNKNOWN"; const clusterInstanceType = instanceSize === "M0" ? "FREE" : "DEDICATED"; + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, @@ -59,7 +73,8 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { instanceSize: clusterInstanceType === "DEDICATED" ? instanceSize : undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -96,3 +111,18 @@ export async function inspectCluster(apiClient: ApiClient, projectId: string, cl } } } + +export async function getProcessIdsFromCluster( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise> { + try { + const cluster = await inspectCluster(apiClient, projectId, clusterName); + return cluster.processIds || []; + } catch (error) { + throw new Error( + `Failed to get processIds from cluster: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/src/common/atlas/openapi.d.ts b/src/common/atlas/openapi.d.ts index 890c45c7c..686f45d48 100644 --- a/src/common/atlas/openapi.d.ts +++ b/src/common/atlas/openapi.d.ts @@ -194,6 +194,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/dropIndexSuggestions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return All Suggested Indexes to Drop + * @description Returns the indexes that the Performance Advisor suggests to drop. The Performance Advisor suggests dropping unused, redundant, and hidden indexes to improve write performance and increase storage space. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listDropIndexes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/schemaAdvice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return Schema Advice + * @description Returns the schema suggestions that the Performance Advisor detects. The Performance Advisor provides holistic schema recommendations for your cluster by sampling documents in your most active collections and collections with slow-running queries. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listSchemaAdvice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/suggestedIndexes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return All Suggested Indexes + * @description Returns the indexes that the Performance Advisor suggests. The Performance Advisor monitors queries that MongoDB considers slow and suggests new indexes to improve query performance. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listClusterSuggestedIndexes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atlas/v2/groups/{groupId}/databaseUsers": { parameters: { query?: never; @@ -286,6 +346,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atlas/v2/groups/{groupId}/processes/{processId}/performanceAdvisor/slowQueryLogs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return Slow Queries + * @description Returns log lines for slow queries that the Performance Advisor and Query Profiler identified. The Performance Advisor monitors queries that MongoDB considers slow and suggests new indexes to improve query performance. MongoDB Cloud bases the threshold for slow queries on the average time of operations on your cluster. This enables workload-relevant recommendations. To use this resource, the requesting Service Account or API Key must have any Project Data Access role or the Project Observability Viewer role. + */ + get: operations["listSlowQueries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atlas/v2/orgs": { parameters: { query?: never; @@ -1735,6 +1815,11 @@ export interface components { * @example 32b6e34b3d91647abb20e7b8 */ readonly roleId?: string; + /** + * @description Provision status of the service account. + * @enum {string} + */ + readonly status?: "IN_PROGRESS" | "COMPLETE" | "FAILED" | "NOT_INITIATED"; } & { /** * @description discriminator enum property added by openapi-typescript @@ -3113,6 +3198,39 @@ export interface components { /** @description Flag that indicates whether this cluster enables disk auto-scaling. The maximum memory allowed for the selected cluster tier and the oplog size can limit storage auto-scaling. */ enabled?: boolean; }; + DropIndexSuggestionsIndex: { + /** + * Format: int64 + * @description Usage count (since last restart) of index. + */ + accessCount?: number; + /** @description List that contains documents that specify a key in the index and its sort order. */ + index?: Record[]; + /** @description Name of index. */ + name?: string; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + namespace?: string; + /** @description List that contains strings that specifies the shards where the index is found. */ + shards?: string[]; + /** + * Format: date-time + * @description Date of most recent usage of index. This parameter expresses its value in the ISO 8601 timestamp format in UTC. + */ + since?: string; + /** + * Format: int64 + * @description Size of index. + */ + sizeBytes?: number; + }; + DropIndexSuggestionsResponse: { + /** @description List that contains the documents with information about the hidden indexes that the Performance Advisor suggests to remove. */ + readonly hiddenIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + /** @description List that contains the documents with information about the redundant indexes that the Performance Advisor suggests to remove. */ + readonly redundantIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + /** @description List that contains the documents with information about the unused indexes that the Performance Advisor suggests to remove. */ + readonly unusedIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + }; /** @description MongoDB employee granted access level and expiration for a cluster. */ EmployeeAccessGrantView: { /** @@ -4379,6 +4497,156 @@ export interface components { */ readonly totalCount?: number; }; + PerformanceAdvisorIndex: { + /** + * Format: double + * @description The average size of an object in the collection of this index. + */ + readonly avgObjSize?: number; + /** + * @description Unique 24-hexadecimal digit string that identifies this index. + * @example 32b6e34b3d91647abb20e7b8 + */ + readonly id?: string; + /** @description List that contains unique 24-hexadecimal character string that identifies the query shapes in this response that the Performance Advisor suggests. */ + readonly impact?: string[]; + /** @description List that contains documents that specify a key in the index and its sort order. */ + readonly index?: { + [key: string]: 1 | -1; + }[]; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** + * Format: double + * @description Estimated performance improvement that the suggested index provides. This value corresponds to **Impact** in the Performance Advisor user interface. + */ + readonly weight?: number; + }; + /** @description Details that this resource returned about the specified query. */ + PerformanceAdvisorOpStats: { + /** + * Format: int64 + * @description Length of time expressed during which the query finds suggested indexes among the managed namespaces in the cluster. This parameter expresses its value in milliseconds. This parameter relates to the **duration** query parameter. + */ + readonly ms?: number; + /** + * Format: int64 + * @description Number of results that the query returns. + */ + readonly nReturned?: number; + /** + * Format: int64 + * @description Number of documents that the query read. + */ + readonly nScanned?: number; + /** + * Format: int64 + * @description Date and time from which the query retrieves the suggested indexes. This parameter expresses its value in the number of seconds that have elapsed since the UNIX epoch. This parameter relates to the **since** query parameter. + */ + readonly ts?: number; + }; + PerformanceAdvisorOperationView: { + /** @description List that contains the search criteria that the query uses. To use the values in key-value pairs in these predicates requires **Project Data Access Read Only** permissions or greater. Otherwise, MongoDB Cloud redacts these values. */ + readonly predicates?: Record[]; + stats?: components["schemas"]["PerformanceAdvisorOpStats"]; + }; + PerformanceAdvisorResponse: { + /** @description List of query predicates, sorts, and projections that the Performance Advisor suggests. */ + readonly shapes?: components["schemas"]["PerformanceAdvisorShape"][]; + /** @description List that contains the documents with information about the indexes that the Performance Advisor suggests. */ + readonly suggestedIndexes?: components["schemas"]["PerformanceAdvisorIndex"][]; + }; + PerformanceAdvisorShape: { + /** + * Format: int64 + * @description Average duration in milliseconds for the queries examined that match this shape. + */ + readonly avgMs?: number; + /** + * Format: int64 + * @description Number of queries examined that match this shape. + */ + readonly count?: number; + /** + * @description Unique 24-hexadecimal digit string that identifies this shape. This string exists only for the duration of this API request. + * @example 32b6e34b3d91647abb20e7b8 + */ + readonly id?: string; + /** + * Format: int64 + * @description Average number of documents read for every document that the query returns. + */ + readonly inefficiencyScore?: number; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** @description List that contains specific about individual queries. */ + readonly operations?: components["schemas"]["PerformanceAdvisorOperationView"][]; + }; + /** @description Details of one slow query that the Performance Advisor detected. */ + PerformanceAdvisorSlowQuery: { + /** @description Text of the MongoDB log related to this slow query. */ + readonly line?: string; + metrics?: components["schemas"]["PerformanceAdvisorSlowQueryMetrics"]; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** @description Operation type (read/write/command) associated with this slow query log. */ + readonly opType?: string; + /** @description Replica state associated with this slow query log. */ + readonly replicaState?: string; + }; + PerformanceAdvisorSlowQueryList: { + /** @description List of operations that the Performance Advisor detected that took longer to execute than a specified threshold. */ + readonly slowQueries?: components["schemas"]["PerformanceAdvisorSlowQuery"][]; + }; + /** @description Metrics from a slow query log. */ + PerformanceAdvisorSlowQueryMetrics: { + /** + * Format: int64 + * @description The number of documents in the collection that MongoDB scanned in order to carry out the operation. + */ + readonly docsExamined?: number; + /** + * Format: double + * @description Ratio of documents examined to documents returned. + */ + readonly docsExaminedReturnedRatio?: number; + /** + * Format: int64 + * @description The number of documents returned by the operation. + */ + readonly docsReturned?: number; + /** @description This boolean will be true when the server can identfiy the query source as non-server. This field is only available for MDB 8.0+. */ + readonly fromUserConnection?: boolean; + /** @description Indicates if the query has index coverage. */ + readonly hasIndexCoverage?: boolean; + /** @description This boolean will be true when a query cannot use the ordering in the index to return the requested sorted results; i.e. MongoDB must sort the documents after it receives the documents from a cursor. */ + readonly hasSort?: boolean; + /** + * Format: int64 + * @description The number of index keys that MongoDB scanned in order to carry out the operation. + */ + readonly keysExamined?: number; + /** + * Format: double + * @description Ratio of keys examined to documents returned. + */ + readonly keysExaminedReturnedRatio?: number; + /** + * Format: int64 + * @description The number of times the operation yielded to allow other operations to complete. + */ + readonly numYields?: number; + /** + * Format: int64 + * @description Total execution time of a query in milliseconds. + */ + readonly operationExecutionTime?: number; + /** + * Format: int64 + * @description The length in bytes of the operation's result document. + */ + readonly responseLength?: number; + }; /** * Periodic Cloud Provider Snapshot Source * @description Scheduled Cloud Provider Snapshot as Source for a Data Lake Pipeline. @@ -4658,6 +4926,36 @@ export interface components { /** @description Variable that belongs to the set of the tag. For example, `production` in the `environment : production` tag. */ value: string; }; + SchemaAdvisorItemRecommendation: { + /** @description List that contains the namespaces and information on why those namespaces triggered the recommendation. */ + readonly affectedNamespaces?: components["schemas"]["SchemaAdvisorNamespaceTriggers"][]; + /** @description Description of the specified recommendation. */ + readonly description?: string; + /** + * @description Type of recommendation. + * @enum {string} + */ + readonly recommendation?: "REDUCE_LOOKUP_OPS" | "AVOID_UNBOUNDED_ARRAY" | "REDUCE_DOCUMENT_SIZE" | "REMOVE_UNNECESSARY_INDEXES" | "REDUCE_NUMBER_OF_NAMESPACES" | "OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES" | "OPTIMIZE_TEXT_QUERIES"; + }; + SchemaAdvisorNamespaceTriggers: { + /** @description Namespace of the affected collection. Will be null for REDUCE_NUMBER_OF_NAMESPACE recommendation. */ + readonly namespace?: string | null; + /** @description List of triggers that specify why the collection activated the recommendation. */ + readonly triggers?: components["schemas"]["SchemaAdvisorTriggerDetails"][]; + }; + SchemaAdvisorResponse: { + /** @description List that contains the documents with information about the schema advice that Performance Advisor suggests. */ + readonly recommendations?: components["schemas"]["SchemaAdvisorItemRecommendation"][]; + }; + SchemaAdvisorTriggerDetails: { + /** @description Description of the trigger type. */ + readonly description?: string; + /** + * @description Type of trigger. + * @enum {string} + */ + readonly triggerType?: "PERCENT_QUERIES_USE_LOOKUP" | "NUMBER_OF_QUERIES_USE_LOOKUP" | "DOCS_CONTAIN_UNBOUNDED_ARRAY" | "NUMBER_OF_NAMESPACES" | "DOC_SIZE_TOO_LARGE" | "NUM_INDEXES" | "QUERIES_CONTAIN_CASE_INSENSITIVE_REGEX"; + }; /** Search Host Status Detail */ SearchHostStatusDetail: { /** @description Hostname that corresponds to the status detail. */ @@ -6368,6 +6666,21 @@ export interface components { "application/json": components["schemas"]["ApiError"]; }; }; + /** @description Too Many Requests. */ + tooManyRequests: { + headers: { + [name: string]: unknown; + }; + content: { + /** @example { + * "detail": "(This is just an example, the exception may not be related to this endpoint)", + * "error": 429, + * "errorCode": "RATE_LIMITED", + * "reason": "Too Many Requests" + * } */ + "application/json": components["schemas"]["ApiError"]; + }; + }; /** @description Unauthorized. */ unauthorized: { headers: { @@ -6524,6 +6837,8 @@ export type DiskBackupSnapshotExportBucketResponse = components['schemas']['Disk export type DiskBackupSnapshotGcpExportBucketRequest = components['schemas']['DiskBackupSnapshotGCPExportBucketRequest']; export type DiskBackupSnapshotGcpExportBucketResponse = components['schemas']['DiskBackupSnapshotGCPExportBucketResponse']; export type DiskGbAutoScaling = components['schemas']['DiskGBAutoScaling']; +export type DropIndexSuggestionsIndex = components['schemas']['DropIndexSuggestionsIndex']; +export type DropIndexSuggestionsResponse = components['schemas']['DropIndexSuggestionsResponse']; export type EmployeeAccessGrantView = components['schemas']['EmployeeAccessGrantView']; export type FieldViolation = components['schemas']['FieldViolation']; export type Fields = components['schemas']['Fields']; @@ -6578,6 +6893,14 @@ export type PaginatedFlexClusters20241113 = components['schemas']['PaginatedFlex export type PaginatedNetworkAccessView = components['schemas']['PaginatedNetworkAccessView']; export type PaginatedOrgGroupView = components['schemas']['PaginatedOrgGroupView']; export type PaginatedOrganizationView = components['schemas']['PaginatedOrganizationView']; +export type PerformanceAdvisorIndex = components['schemas']['PerformanceAdvisorIndex']; +export type PerformanceAdvisorOpStats = components['schemas']['PerformanceAdvisorOpStats']; +export type PerformanceAdvisorOperationView = components['schemas']['PerformanceAdvisorOperationView']; +export type PerformanceAdvisorResponse = components['schemas']['PerformanceAdvisorResponse']; +export type PerformanceAdvisorShape = components['schemas']['PerformanceAdvisorShape']; +export type PerformanceAdvisorSlowQuery = components['schemas']['PerformanceAdvisorSlowQuery']; +export type PerformanceAdvisorSlowQueryList = components['schemas']['PerformanceAdvisorSlowQueryList']; +export type PerformanceAdvisorSlowQueryMetrics = components['schemas']['PerformanceAdvisorSlowQueryMetrics']; export type PeriodicCpsSnapshotSource = components['schemas']['PeriodicCpsSnapshotSource']; export type RawMetricAlertView = components['schemas']['RawMetricAlertView']; export type RawMetricUnits = components['schemas']['RawMetricUnits']; @@ -6586,6 +6909,10 @@ export type ReplicaSetAlertViewForNdsGroup = components['schemas']['ReplicaSetAl export type ReplicaSetEventTypeViewForNdsGroupAlertable = components['schemas']['ReplicaSetEventTypeViewForNdsGroupAlertable']; export type ReplicationSpec20240805 = components['schemas']['ReplicationSpec20240805']; export type ResourceTag = components['schemas']['ResourceTag']; +export type SchemaAdvisorItemRecommendation = components['schemas']['SchemaAdvisorItemRecommendation']; +export type SchemaAdvisorNamespaceTriggers = components['schemas']['SchemaAdvisorNamespaceTriggers']; +export type SchemaAdvisorResponse = components['schemas']['SchemaAdvisorResponse']; +export type SchemaAdvisorTriggerDetails = components['schemas']['SchemaAdvisorTriggerDetails']; export type SearchHostStatusDetail = components['schemas']['SearchHostStatusDetail']; export type SearchIndex = components['schemas']['SearchIndex']; export type SearchIndexCreateRequest = components['schemas']['SearchIndexCreateRequest']; @@ -6675,6 +7002,7 @@ export type ResponseForbidden = components['responses']['forbidden']; export type ResponseInternalServerError = components['responses']['internalServerError']; export type ResponseNotFound = components['responses']['notFound']; export type ResponsePaymentRequired = components['responses']['paymentRequired']; +export type ResponseTooManyRequests = components['responses']['tooManyRequests']; export type ResponseUnauthorized = components['responses']['unauthorized']; export type ParameterEnvelope = components['parameters']['envelope']; export type ParameterGroupId = components['parameters']['groupId']; @@ -7194,6 +7522,120 @@ export interface operations { 500: components["responses"]["internalServerError"]; }; }; + listDropIndexes: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + /** @description Human-readable label that identifies the cluster. */ + clusterName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["DropIndexSuggestionsResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; + listSchemaAdvice: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + /** @description Human-readable label that identifies the cluster. */ + clusterName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["SchemaAdvisorResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; + listClusterSuggestedIndexes: { + parameters: { + query?: { + /** @description ProcessIds from which to retrieve suggested indexes. A processId is a combination of host and port that serves the MongoDB process. The host must be the hostname, FQDN, IPv4 address, or IPv6 address of the host that runs the MongoDB process (`mongod` or `mongos`). The port must be the IANA port on which the MongoDB process listens for requests. To include multiple processIds, pass the parameter multiple times delimited with an ampersand (`&`) between each processId. */ + processIds?: string[]; + /** @description Namespaces from which to retrieve suggested indexes. A namespace consists of one database and one collection resource written as `.`: `.`. To include multiple namespaces, pass the parameter multiple times delimited with an ampersand (`&`) between each namespace. Omit this parameter to return results for all namespaces. */ + namespaces?: string[]; + /** @description Date and time from which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + * + * - If you don't specify the **until** parameter, the endpoint returns data covering from the **since** value and the current time. + * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. */ + since?: number; + /** @description Date and time up until which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + * + * - If you specify the **until** parameter, you must specify the **since** parameter. + * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. */ + until?: number; + }; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + /** @description Human-readable label that identifies the cluster. */ + clusterName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["PerformanceAdvisorResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; listDatabaseUsers: { parameters: { query?: { @@ -7485,6 +7927,63 @@ export interface operations { 500: components["responses"]["internalServerError"]; }; }; + listSlowQueries: { + parameters: { + query?: { + /** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */ + envelope?: components["parameters"]["envelope"]; + /** @description Flag that indicates whether the response body should be in the prettyprint format. */ + pretty?: components["parameters"]["pretty"]; + /** @description Length of time expressed during which the query finds slow queries among the managed namespaces in the cluster. This parameter expresses its value in milliseconds. + * + * - If you don't specify the **since** parameter, the endpoint returns data covering the duration before the current time. + * - If you specify neither the **duration** nor **since** parameters, the endpoint returns data from the previous 24 hours. */ + duration?: number; + /** @description Namespaces from which to retrieve slow queries. A namespace consists of one database and one collection resource written as `.`: `.`. To include multiple namespaces, pass the parameter multiple times delimited with an ampersand (`&`) between each namespace. Omit this parameter to return results for all namespaces. */ + namespaces?: string[]; + /** @description Maximum number of lines from the log to return. */ + nLogs?: number; + /** @description Date and time from which the query retrieves the slow queries. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + * + * - If you don't specify the **duration** parameter, the endpoint returns data covering from the **since** value and the current time. + * - If you specify neither the **duration** nor the **since** parameters, the endpoint returns data from the previous 24 hours. */ + since?: number; + /** @description Whether or not to include metrics extracted from the slow query log as separate fields. */ + includeMetrics?: boolean; + /** @description Whether or not to include the replica state of the host when the slow query log was generated as a separate field. */ + includeReplicaState?: boolean; + /** @description Whether or not to include the operation type (read/write/command) extracted from the slow query log as a separate field. */ + includeOpType?: boolean; + }; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + /** @description Combination of host and port that serves the MongoDB process. The host must be the hostname, FQDN, IPv4 address, or IPv6 address of the host that runs the MongoDB process (`mongod` or `mongos`). The port must be the IANA port on which the MongoDB process listens for requests. */ + processId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2023-01-01+json": components["schemas"]["PerformanceAdvisorSlowQueryList"]; + }; + }; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; listOrganizations: { parameters: { query?: { diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts new file mode 100644 index 000000000..61a79d188 --- /dev/null +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -0,0 +1,156 @@ +import { LogId } from "../logger.js"; +import type { ApiClient } from "./apiClient.js"; +import { getProcessIdsFromCluster } from "./cluster.js"; +import type { components } from "./openapi.js"; + +export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"]; +export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"]; +export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"]; + +export const DEFAULT_SLOW_QUERY_LOGS_LIMIT = 50; + +export const SUGGESTED_INDEXES_COPY = `Note: The "Weight" field is measured in bytes, and represents the estimated number of bytes saved in disk reads per executed read query that would be saved by implementing an index suggestion. Please convert this to MB or GB for easier readability.`; +export const SLOW_QUERY_LOGS_COPY = `Please notify the user that the MCP server tool limits slow query logs to the most recent ${DEFAULT_SLOW_QUERY_LOGS_LIMIT} slow query logs. This is a limitation of the MCP server tool only. More slow query logs and performance suggestions can be seen in the Atlas UI. Please give to the user the following docs about the performance advisor: https://www.mongodb.com/docs/atlas/performance-advisor/.`; + +interface SuggestedIndexesResponse { + content: components["schemas"]["PerformanceAdvisorResponse"]; +} +interface DropIndexesResponse { + content: components["schemas"]["DropIndexSuggestionsResponse"]; +} +interface SchemaAdviceResponse { + content: components["schemas"]["SchemaAdvisorResponse"]; +} +export type SchemaRecommendation = components["schemas"]["SchemaAdvisorItemRecommendation"]; + +export async function getSuggestedIndexes( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ suggestedIndexes: Array }> { + try { + const response = await apiClient.listClusterSuggestedIndexes({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { + suggestedIndexes: (response as SuggestedIndexesResponse).content.suggestedIndexes ?? [], + }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSuggestedIndexesFailure, + context: "performanceAdvisorUtils", + message: `Failed to list suggested indexes: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list suggested indexes: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getDropIndexSuggestions( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ + hiddenIndexes: Array; + redundantIndexes: Array; + unusedIndexes: Array; +}> { + try { + const response = await apiClient.listDropIndexes({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { + hiddenIndexes: (response as DropIndexesResponse).content.hiddenIndexes ?? [], + redundantIndexes: (response as DropIndexesResponse).content.redundantIndexes ?? [], + unusedIndexes: (response as DropIndexesResponse).content.unusedIndexes ?? [], + }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaDropIndexSuggestionsFailure, + context: "performanceAdvisorUtils", + message: `Failed to list drop index suggestions: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list drop index suggestions: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getSchemaAdvice( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ recommendations: Array }> { + try { + const response = await apiClient.listSchemaAdvice({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { recommendations: (response as SchemaAdviceResponse).content.recommendations ?? [] }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSchemaAdviceFailure, + context: "performanceAdvisorUtils", + message: `Failed to list schema advice: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list schema advice: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getSlowQueries( + apiClient: ApiClient, + projectId: string, + clusterName: string, + since?: Date, + namespaces?: Array +): Promise<{ slowQueryLogs: Array }> { + try { + const processIds = await getProcessIdsFromCluster(apiClient, projectId, clusterName); + + if (processIds.length === 0) { + return { slowQueryLogs: [] }; + } + + const slowQueryPromises = processIds.map((processId) => + apiClient.listSlowQueries({ + params: { + path: { + groupId: projectId, + processId, + }, + query: { + ...(since && { since: since.getTime() }), + ...(namespaces && { namespaces: namespaces }), + nLogs: DEFAULT_SLOW_QUERY_LOGS_LIMIT, + }, + }, + }) + ); + + const responses = await Promise.allSettled(slowQueryPromises); + + const allSlowQueryLogs = responses.reduce((acc, response) => { + return acc.concat(response.status === "fulfilled" ? (response.value.slowQueries ?? []) : []); + }, [] as Array); + + return { slowQueryLogs: allSlowQueryLogs }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSlowQueryLogsFailure, + context: "performanceAdvisorUtils", + message: `Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/src/common/config.ts b/src/common/config.ts index cbac900c4..b7bf527b1 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -53,6 +53,7 @@ const OPTIONS = { "exportsPath", "exportTimeoutMs", "exportCleanupIntervalMs", + "voyageApiKey", ], boolean: [ "apiDeprecationErrors", @@ -181,6 +182,9 @@ export interface UserConfig extends CliOptions { maxDocumentsPerQuery: number; maxBytesPerQuery: number; atlasTemporaryDatabaseUserLifetimeMs: number; + voyageApiKey: string; + vectorSearchDimensions: number; + vectorSearchSimilarityFunction: "cosine" | "euclidean" | "dotProduct"; } export const defaultUserConfig: UserConfig = { @@ -199,6 +203,7 @@ export const defaultUserConfig: UserConfig = { "drop-database", "drop-collection", "delete-many", + "drop-index", ], transport: "stdio", httpPort: 3000, @@ -210,6 +215,9 @@ export const defaultUserConfig: UserConfig = { maxDocumentsPerQuery: 100, // By default, we only fetch a maximum 100 documents per query / aggregation maxBytesPerQuery: 16 * 1024 * 1024, // By default, we only return ~16 mb of data per query / aggregation atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours + voyageApiKey: "", + vectorSearchDimensions: 1024, + vectorSearchSimilarityFunction: "euclidean", }; export const config = setupUserConfig({ diff --git a/src/common/connectionManager.ts b/src/common/connectionManager.ts index 1094f8453..22ab2959b 100644 --- a/src/common/connectionManager.ts +++ b/src/common/connectionManager.ts @@ -32,9 +32,33 @@ export interface ConnectionState { connectedAtlasCluster?: AtlasClusterConnectionInfo; } -export interface ConnectionStateConnected extends ConnectionState { - tag: "connected"; - serviceProvider: NodeDriverServiceProvider; +export class ConnectionStateConnected implements ConnectionState { + public tag = "connected" as const; + + constructor( + public serviceProvider: NodeDriverServiceProvider, + public connectionStringAuthType?: ConnectionStringAuthType, + public connectedAtlasCluster?: AtlasClusterConnectionInfo + ) {} + + private _isSearchSupported?: boolean; + + public async isSearchSupported(): Promise { + if (this._isSearchSupported === undefined) { + try { + const dummyDatabase = "test"; + const dummyCollection = "test"; + // If a cluster supports search indexes, the call below will succeed + // with a cursor otherwise will throw an Error + await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection); + this._isSearchSupported = true; + } catch { + this._isSearchSupported = false; + } + } + + return this._isSearchSupported; + } } export interface ConnectionStateConnecting extends ConnectionState { @@ -199,12 +223,10 @@ export class MCPConnectionManager extends ConnectionManager { }); } - return this.changeState("connection-success", { - tag: "connected", - connectedAtlasCluster: settings.atlas, - serviceProvider: await serviceProvider, - connectionStringAuthType, - }); + return this.changeState( + "connection-success", + new ConnectionStateConnected(await serviceProvider, connectionStringAuthType, settings.atlas) + ); } catch (error: unknown) { const errorReason = error instanceof Error ? error.message : `${error as string}`; this.changeState("connection-error", { @@ -270,11 +292,14 @@ export class MCPConnectionManager extends ConnectionManager { this.currentConnectionState.tag === "connecting" && this.currentConnectionState.connectionStringAuthType?.startsWith("oidc") ) { - this.changeState("connection-success", { - ...this.currentConnectionState, - tag: "connected", - serviceProvider: await this.currentConnectionState.serviceProvider, - }); + this.changeState( + "connection-success", + new ConnectionStateConnected( + await this.currentConnectionState.serviceProvider, + this.currentConnectionState.connectionStringAuthType, + this.currentConnectionState.connectedAtlasCluster + ) + ); } this.logger.info({ diff --git a/src/common/logger.ts b/src/common/logger.ts index 07b126aa4..100191f91 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -70,6 +70,11 @@ export const LogId = { exportLockError: mongoLogId(1_007_008), oidcFlow: mongoLogId(1_008_001), + + atlasPaSuggestedIndexesFailure: mongoLogId(1_009_001), + atlasPaDropIndexSuggestionsFailure: mongoLogId(1_009_002), + atlasPaSchemaAdviceFailure: mongoLogId(1_009_003), + atlasPaSlowQueryLogsFailure: mongoLogId(1_009_004), } as const; export interface LogPayload { diff --git a/src/common/packageInfo.ts b/src/common/packageInfo.ts index 813189e22..ae3728f03 100644 --- a/src/common/packageInfo.ts +++ b/src/common/packageInfo.ts @@ -1,5 +1,5 @@ // This file was generated by scripts/updatePackageVersion.ts - Do not edit it manually. export const packageInfo = { - version: "1.0.2", + version: "1.1.0", mcpServerName: "MongoDB MCP Server", }; diff --git a/src/common/session.ts b/src/common/session.ts index 3c702a645..4607f17ba 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -141,6 +141,15 @@ export class Session extends EventEmitter { return this.connectionManager.currentConnectionState.tag === "connected"; } + isSearchSupported(): Promise { + const state = this.connectionManager.currentConnectionState; + if (state.tag === "connected") { + return state.isSearchSupported(); + } + + return Promise.resolve(false); + } + get serviceProvider(): NodeDriverServiceProvider { if (this.isConnectedToMongoDB) { const state = this.connectionManager.currentConnectionState as ConnectionStateConnected; diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts index ad1f383df..29bc26401 100644 --- a/src/resources/common/debug.ts +++ b/src/resources/common/debug.ts @@ -56,13 +56,15 @@ export class DebugResource extends ReactiveResource< } } - toOutput(): string { + async toOutput(): Promise { let result = ""; switch (this.current.tag) { - case "connected": - result += "The user is connected to the MongoDB cluster."; + case "connected": { + const searchIndexesSupported = await this.session.isSearchSupported(); + result += `The user is connected to the MongoDB cluster${searchIndexesSupported ? " with support for search indexes" : " without any support for search indexes"}.`; break; + } case "errored": result += `The user is not connected to a MongoDB cluster because of an error.\n`; if (this.current.connectedAtlasCluster) { diff --git a/src/resources/resource.ts b/src/resources/resource.ts index cf265a490..a9cb702ac 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -73,10 +73,10 @@ export abstract class ReactiveResource ({ + private resourceCallback: ReadResourceCallback = async (uri) => ({ contents: [ { - text: this.toOutput(), + text: await this.toOutput(), mimeType: "application/json", uri: uri.href, }, @@ -101,5 +101,5 @@ export abstract class ReactiveResource[]): Value; - public abstract toOutput(): string; + public abstract toOutput(): string | Promise; } diff --git a/src/server.ts b/src/server.ts index 458bcd28b..f8aa3226b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,7 +18,7 @@ import { UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import assert from "assert"; -import type { ToolBase } from "./tools/tool.js"; +import type { ToolBase, ToolConstructorParams } from "./tools/tool.js"; import { validateConnectionString } from "./helpers/connectionOptions.js"; import { packageInfo } from "./common/packageInfo.js"; import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js"; @@ -31,6 +31,7 @@ export interface ServerOptions { telemetry: Telemetry; elicitation: Elicitation; connectionErrorHandler: ConnectionErrorHandler; + toolConstructors?: (new (params: ToolConstructorParams) => ToolBase)[]; } export class Server { @@ -39,6 +40,7 @@ export class Server { private readonly telemetry: Telemetry; public readonly userConfig: UserConfig; public readonly elicitation: Elicitation; + private readonly toolConstructors: (new (params: ToolConstructorParams) => ToolBase)[]; public readonly tools: ToolBase[] = []; public readonly connectionErrorHandler: ConnectionErrorHandler; @@ -51,7 +53,15 @@ export class Server { private readonly startTime: number; private readonly subscriptions = new Set(); - constructor({ session, mcpServer, userConfig, telemetry, connectionErrorHandler, elicitation }: ServerOptions) { + constructor({ + session, + mcpServer, + userConfig, + telemetry, + connectionErrorHandler, + elicitation, + toolConstructors, + }: ServerOptions) { this.startTime = Date.now(); this.session = session; this.telemetry = telemetry; @@ -59,6 +69,7 @@ export class Server { this.userConfig = userConfig; this.elicitation = elicitation; this.connectionErrorHandler = connectionErrorHandler; + this.toolConstructors = toolConstructors ?? [...AtlasTools, ...MongoDbTools]; } async connect(transport: Transport): Promise { @@ -190,7 +201,7 @@ export class Server { if (command === "start") { event.properties.startup_time_ms = commandDuration; - event.properties.read_only_mode = this.userConfig.readOnly || false; + event.properties.read_only_mode = this.userConfig.readOnly ? "true" : "false"; event.properties.disabled_tools = this.userConfig.disabledTools || []; event.properties.confirmation_required_tools = this.userConfig.confirmationRequiredTools || []; } @@ -206,7 +217,7 @@ export class Server { } private registerTools(): void { - for (const toolConstructor of [...AtlasTools, ...MongoDbTools]) { + for (const toolConstructor of this.toolConstructors) { const tool = new toolConstructor({ session: this.session, config: this.userConfig, diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index bdba51a51..6a9db5c1b 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -8,6 +8,7 @@ import { EventCache } from "./eventCache.js"; import { detectContainerEnv } from "../helpers/container.js"; import type { DeviceId } from "../helpers/deviceId.js"; import { EventEmitter } from "events"; +import { redact } from "mongodb-redact"; type EventResult = { success: boolean; @@ -79,7 +80,7 @@ export class Telemetry { const [deviceIdValue, containerEnv] = await this.setupPromise; this.commonProperties.device_id = deviceIdValue; - this.commonProperties.is_container_env = containerEnv; + this.commonProperties.is_container_env = containerEnv ? "true" : "false"; this.isBufferingEvents = false; } @@ -123,7 +124,6 @@ export class Telemetry { this.events.emit("events-skipped"); return; } - // Don't wait for events to be sent - we should not block regular server // operations on telemetry void this.emit(events); @@ -213,14 +213,18 @@ export class Telemetry { } /** - * Attempts to send events through the provided API client + * Attempts to send events through the provided API client. + * Events are redacted before being sent to ensure no sensitive data is transmitted */ private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise { try { await client.sendEvents( events.map((event) => ({ ...event, - properties: { ...this.getCommonProperties(), ...event.properties }, + properties: { + ...redact(this.getCommonProperties(), this.session.keychain.allSecrets), + ...redact(event.properties, this.session.keychain.allSecrets), + }, })) ); return { success: true }; diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index c1eced5af..0c32799a3 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -43,7 +43,7 @@ export type ServerEventProperties = { reason?: string; startup_time_ms?: number; runtime_duration_ms?: number; - read_only_mode?: boolean; + read_only_mode?: TelemetryBoolSet; disabled_tools?: string[]; confirmation_required_tools?: string[]; }; @@ -97,7 +97,7 @@ export type CommonProperties = { /** * A boolean indicating whether the server is running in a container environment. */ - is_container_env?: boolean; + is_container_env?: TelemetryBoolSet; /** * The version of the MCP client as reported by the client on session establishment. diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index b68eeafde..8d8914d67 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -38,6 +38,21 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs }; } + if (statusCode === 402) { + return { + content: [ + { + type: "text", + text: `Received a Payment Required API Error: ${error.message} + +Payment setup is required to perform this action in MongoDB Atlas. +Please ensure that your payment method for your organization has been set up and is active. +For more information on setting up payment, visit: https://www.mongodb.com/docs/atlas/billing/`, + }, + ], + }; + } + if (statusCode === 403) { return { content: [ @@ -45,7 +60,7 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs type: "text", text: `Received a Forbidden API Error: ${error.message} -You don't have sufficient permissions to perform this action in MongoDB Atlas +You don't have sufficient permissions to perform this action in MongoDB Atlas. Please ensure your API key has the necessary roles assigned. For more information on Atlas API access roles, visit: https://www.mongodb.com/docs/atlas/api/service-accounts-overview/`, }, diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts new file mode 100644 index 000000000..fa7ec5194 --- /dev/null +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import { AtlasToolBase } from "../atlasTool.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { formatUntrustedData } from "../../tool.js"; +import { + getSuggestedIndexes, + getDropIndexSuggestions, + getSchemaAdvice, + getSlowQueries, + DEFAULT_SLOW_QUERY_LOGS_LIMIT, + SUGGESTED_INDEXES_COPY, + SLOW_QUERY_LOGS_COPY, +} from "../../../common/atlas/performanceAdvisorUtils.js"; +import { AtlasArgs } from "../../args.js"; + +const PerformanceAdvisorOperationType = z.enum([ + "suggestedIndexes", + "dropIndexSuggestions", + "slowQueryLogs", + "schemaSuggestions", +]); + +export class GetPerformanceAdvisorTool extends AtlasToolBase { + public name = "atlas-get-performance-advisor"; + protected description = `Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, schema suggestions, and a sample of the most recent (max ${DEFAULT_SLOW_QUERY_LOGS_LIMIT}) slow query logs`; + public operationType: OperationType = "read"; + protected argsShape = { + projectId: AtlasArgs.projectId().describe("Atlas project ID to get performance advisor recommendations"), + clusterName: AtlasArgs.clusterName().describe("Atlas cluster name to get performance advisor recommendations"), + operations: z + .array(PerformanceAdvisorOperationType) + .default(PerformanceAdvisorOperationType.options) + .describe("Operations to get performance advisor recommendations"), + since: z + .string() + .datetime() + .describe( + "Date to get slow query logs since. Must be a string in ISO 8601 format. Only relevant for the slowQueryLogs operation." + ) + .optional(), + namespaces: z + .array(z.string()) + .describe("Namespaces to get slow query logs. Only relevant for the slowQueryLogs operation.") + .optional(), + }; + + protected async execute({ + projectId, + clusterName, + operations, + since, + namespaces, + }: ToolArgs): Promise { + try { + const [suggestedIndexesResult, dropIndexSuggestionsResult, slowQueryLogsResult, schemaSuggestionsResult] = + await Promise.allSettled([ + operations.includes("suggestedIndexes") + ? getSuggestedIndexes(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + operations.includes("dropIndexSuggestions") + ? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + operations.includes("slowQueryLogs") + ? getSlowQueries( + this.session.apiClient, + projectId, + clusterName, + since ? new Date(since) : undefined, + namespaces + ) + : Promise.resolve(undefined), + operations.includes("schemaSuggestions") + ? getSchemaAdvice(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + ]); + + const hasSuggestedIndexes = + suggestedIndexesResult.status === "fulfilled" && + suggestedIndexesResult.value?.suggestedIndexes && + suggestedIndexesResult.value.suggestedIndexes.length > 0; + const hasDropIndexSuggestions = + dropIndexSuggestionsResult.status === "fulfilled" && + dropIndexSuggestionsResult.value?.hiddenIndexes && + dropIndexSuggestionsResult.value?.redundantIndexes && + dropIndexSuggestionsResult.value?.unusedIndexes && + (dropIndexSuggestionsResult.value.hiddenIndexes.length > 0 || + dropIndexSuggestionsResult.value.redundantIndexes.length > 0 || + dropIndexSuggestionsResult.value.unusedIndexes.length > 0); + const hasSlowQueryLogs = + slowQueryLogsResult.status === "fulfilled" && + slowQueryLogsResult.value?.slowQueryLogs && + slowQueryLogsResult.value.slowQueryLogs.length > 0; + const hasSchemaSuggestions = + schemaSuggestionsResult.status === "fulfilled" && + schemaSuggestionsResult.value?.recommendations && + schemaSuggestionsResult.value.recommendations.length > 0; + + // Inserts the performance advisor data with the relevant section header if it exists + const performanceAdvisorData = [ + `## Suggested Indexes\n${ + hasSuggestedIndexes + ? `${SUGGESTED_INDEXES_COPY}\n${JSON.stringify(suggestedIndexesResult.value?.suggestedIndexes)}` + : "No suggested indexes found." + }`, + `## Drop Index Suggestions\n${hasDropIndexSuggestions ? JSON.stringify(dropIndexSuggestionsResult.value) : "No drop index suggestions found."}`, + `## Slow Query Logs\n${hasSlowQueryLogs ? `${SLOW_QUERY_LOGS_COPY}\n${JSON.stringify(slowQueryLogsResult.value?.slowQueryLogs)}` : "No slow query logs found."}`, + `## Schema Suggestions\n${hasSchemaSuggestions ? JSON.stringify(schemaSuggestionsResult.value?.recommendations) : "No schema suggestions found."}`, + ]; + + if (performanceAdvisorData.length === 0) { + return { + content: [{ type: "text", text: "No performance advisor recommendations found." }], + }; + } + + return { + content: formatUntrustedData("Performance advisor data", performanceAdvisorData.join("\n\n")), + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error retrieving performance advisor data: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index c43b88ef7..c2822ec55 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -10,6 +10,7 @@ import { CreateProjectTool } from "./create/createProject.js"; import { ListOrganizationsTool } from "./read/listOrgs.js"; import { ConnectClusterTool } from "./connect/connectCluster.js"; import { ListAlertsTool } from "./read/listAlerts.js"; +import { GetPerformanceAdvisorTool } from "./read/getPerformanceAdvisor.js"; export const AtlasTools = [ ListClustersTool, @@ -24,4 +25,5 @@ export const AtlasTools = [ ListOrganizationsTool, ConnectClusterTool, ListAlertsTool, + GetPerformanceAdvisorTool, ]; diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index d87b9df0b..f4ac313ea 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -1,16 +1,88 @@ import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; +import type { ToolCategory } from "../../tool.js"; +import { type ToolArgs, type OperationType, FeatureFlags } from "../../tool.js"; import type { IndexDirection } from "mongodb"; export class CreateIndexTool extends MongoDBToolBase { + private vectorSearchIndexDefinition = z.object({ + type: z.literal("vectorSearch"), + fields: z + .array( + z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("filter"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + }) + .strict() + .describe("Definition for a field that will be used for pre-filtering results."), + z + .object({ + type: z.literal("vector"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .default(this.config.vectorSearchDimensions) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .default(this.config.vectorSearchSimilarityFunction) + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }) + .strict() + .describe("Definition for a field that contains vector embeddings."), + ]) + ) + .nonempty() + .refine((fields) => fields.some((f) => f.type === "vector"), { + message: "At least one vector field must be defined", + }) + .describe( + "Definitions for the vector and filter fields to index, one definition per document. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on. At least one vector-type field definition is required." + ), + }); + public name = "create-index"; protected description = "Create an index for a collection"; protected argsShape = { ...DbOperationArgs, - keys: z.object({}).catchall(z.custom()).describe("The index definition"), name: z.string().optional().describe("The name of the index"), + definition: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("classic"), + keys: z.object({}).catchall(z.custom()).describe("The index definition"), + }), + ...(this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) ? [this.vectorSearchIndexDefinition] : []), + ]) + ) + .describe( + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" + ), }; public operationType: OperationType = "create"; @@ -18,21 +90,69 @@ export class CreateIndexTool extends MongoDBToolBase { protected async execute({ database, collection, - keys, name, + definition: definitions, }: ToolArgs): Promise { const provider = await this.ensureConnected(); - const indexes = await provider.createIndexes(database, collection, [ - { - key: keys, - name, - }, - ]); + let indexes: string[] = []; + const definition = definitions[0]; + if (!definition) { + throw new Error("Index definition not provided. Expected one of the following: `classic`, `vectorSearch`"); + } + + let responseClarification = ""; + + switch (definition.type) { + case "classic": + indexes = await provider.createIndexes(database, collection, [ + { + key: definition.keys, + name, + }, + ]); + break; + case "vectorSearch": + { + const isVectorSearchSupported = await this.session.isSearchSupported(); + if (!isVectorSearchSupported) { + // TODO: remove hacky casts once we merge the local dev tools + const isLocalAtlasAvailable = + (this.server?.tools.filter((t) => t.category === ("atlas-local" as unknown as ToolCategory)) + .length ?? 0) > 0; + + const CTA = isLocalAtlasAvailable ? "`atlas-local` tools" : "Atlas CLI"; + return { + content: [ + { + text: `The connected MongoDB deployment does not support vector search indexes. Either connect to a MongoDB Atlas cluster or use the ${CTA} to create and manage a local Atlas deployment.`, + type: "text", + }, + ], + isError: true, + }; + } + + indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + definition: { + fields: definition.fields, + }, + type: "vectorSearch", + }, + ]); + + responseClarification = + " Since this is a vector search index, it may take a while for the index to build. Use the `list-indexes` tool to check the index status."; + } + + break; + } return { content: [ { - text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}"`, + text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}".${responseClarification}`, type: "text", }, ], diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts new file mode 100644 index 000000000..e87db4171 --- /dev/null +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -0,0 +1,45 @@ +import z from "zod"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { type ToolArgs, type OperationType, formatUntrustedData } from "../../tool.js"; + +export class DropIndexTool extends MongoDBToolBase { + public name = "drop-index"; + protected description = "Drop an index for the provided database and collection."; + protected argsShape = { + ...DbOperationArgs, + indexName: z.string().nonempty().describe("The name of the index to be dropped."), + }; + public operationType: OperationType = "delete"; + + protected async execute({ + database, + collection, + indexName, + }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + const result = await provider.runCommand(database, { + dropIndexes: collection, + index: indexName, + }); + + return { + content: formatUntrustedData( + `${result.ok ? "Successfully dropped" : "Failed to drop"} the index from the provided namespace.`, + JSON.stringify({ + indexName, + namespace: `${database}.${collection}`, + }) + ), + isError: result.ok ? undefined : true, + }; + } + + protected getConfirmationMessage({ database, collection, indexName }: ToolArgs): string { + return ( + `You are about to drop the \`${indexName}\` index from the \`${database}.${collection}\` namespace:\n\n` + + "This operation will permanently remove the index and might affect the performance of queries relying on this index.\n\n" + + "**Do you confirm the execution of the action?**" + ); + } +} diff --git a/src/tools/mongodb/read/collectionIndexes.ts b/src/tools/mongodb/metadata/collectionIndexes.ts similarity index 95% rename from src/tools/mongodb/read/collectionIndexes.ts rename to src/tools/mongodb/metadata/collectionIndexes.ts index 818561917..6da2c7886 100644 --- a/src/tools/mongodb/read/collectionIndexes.ts +++ b/src/tools/mongodb/metadata/collectionIndexes.ts @@ -7,7 +7,7 @@ export class CollectionIndexesTool extends MongoDBToolBase { public name = "collection-indexes"; protected description = "Describe the indexes for a collection"; protected argsShape = DbOperationArgs; - public operationType: OperationType = "read"; + public operationType: OperationType = "metadata"; protected async execute({ database, collection }: ToolArgs): Promise { const provider = await this.ensureConnected(); @@ -37,6 +37,7 @@ export class CollectionIndexesTool extends MongoDBToolBase { type: "text", }, ], + isError: true, }; } diff --git a/src/tools/mongodb/metadata/collectionStorageSize.ts b/src/tools/mongodb/metadata/collectionStorageSize.ts index 91a1c51e0..c38ccc076 100644 --- a/src/tools/mongodb/metadata/collectionStorageSize.ts +++ b/src/tools/mongodb/metadata/collectionStorageSize.ts @@ -42,6 +42,7 @@ export class CollectionStorageSizeTool extends MongoDBToolBase { type: "text", }, ], + isError: true, }; } diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index ded994ab3..2b9010364 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -13,7 +13,7 @@ export const DbOperationArgs = { }; export abstract class MongoDBToolBase extends ToolBase { - private server?: Server; + protected server?: Server; public category: ToolCategory = "mongodb"; protected async ensureConnected(): Promise { diff --git a/src/tools/mongodb/search/listSearchIndexes.ts b/src/tools/mongodb/search/listSearchIndexes.ts new file mode 100644 index 000000000..1b520d523 --- /dev/null +++ b/src/tools/mongodb/search/listSearchIndexes.ts @@ -0,0 +1,81 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ToolArgs, OperationType } from "../../tool.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { formatUntrustedData } from "../../tool.js"; +import { EJSON } from "bson"; + +export type SearchIndexStatus = { + name: string; + type: string; + status: string; + queryable: boolean; + latestDefinition: Document; +}; + +export class ListSearchIndexesTool extends MongoDBToolBase { + public name = "list-search-indexes"; + protected description = "Describes the search and vector search indexes for a single collection"; + protected argsShape = DbOperationArgs; + public operationType: OperationType = "metadata"; + + protected async execute({ database, collection }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + const indexes = await provider.getSearchIndexes(database, collection); + const trimmedIndexDefinitions = this.pickRelevantInformation(indexes); + + if (trimmedIndexDefinitions.length > 0) { + return { + content: formatUntrustedData( + `Found ${trimmedIndexDefinitions.length} search and vector search indexes in ${database}.${collection}`, + trimmedIndexDefinitions.map((index) => EJSON.stringify(index)).join("\n") + ), + }; + } else { + return { + content: formatUntrustedData( + "Could not retrieve search indexes", + `There are no search or vector search indexes in ${database}.${collection}` + ), + }; + } + } + + protected verifyAllowed(): boolean { + // Only enable this on tests for now. + return process.env.VITEST === "true"; + } + + /** + * Atlas Search index status contains a lot of information that is not relevant for the agent at this stage. + * Like for example, the status on each of the dedicated nodes. We only care about the main status, if it's + * queryable and the index name. We are also picking the index definition as it can be used by the agent to + * understand which fields are available for searching. + **/ + protected pickRelevantInformation(indexes: Record[]): SearchIndexStatus[] { + return indexes.map((index) => ({ + name: (index["name"] ?? "default") as string, + type: (index["type"] ?? "UNKNOWN") as string, + status: (index["status"] ?? "UNKNOWN") as string, + queryable: (index["queryable"] ?? false) as boolean, + latestDefinition: index["latestDefinition"] as Document, + })); + } + + protected handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + if (error instanceof Error && "codeName" in error && error.codeName === "SearchNotEnabled") { + return { + content: [ + { + text: "This MongoDB cluster does not support Search Indexes. Make sure you are using an Atlas Cluster, either remotely in Atlas or using the Atlas Local image, or your cluster supports MongoDB Search.", + type: "text", + isError: true, + }, + ], + }; + } + return super.handleError(error, args); + } +} diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index 00575ee05..6e96b2ba6 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -1,6 +1,6 @@ import { ConnectTool } from "./connect/connect.js"; import { ListCollectionsTool } from "./metadata/listCollections.js"; -import { CollectionIndexesTool } from "./read/collectionIndexes.js"; +import { CollectionIndexesTool } from "./metadata/collectionIndexes.js"; import { ListDatabasesTool } from "./metadata/listDatabases.js"; import { CreateIndexTool } from "./create/createIndex.js"; import { CollectionSchemaTool } from "./metadata/collectionSchema.js"; @@ -19,12 +19,15 @@ import { ExplainTool } from "./metadata/explain.js"; import { CreateCollectionTool } from "./create/createCollection.js"; import { LogsTool } from "./metadata/logs.js"; import { ExportTool } from "./read/export.js"; +import { ListSearchIndexesTool } from "./search/listSearchIndexes.js"; +import { DropIndexTool } from "./delete/dropIndex.js"; export const MongoDbTools = [ ConnectTool, ListCollectionsTool, ListDatabasesTool, CollectionIndexesTool, + DropIndexTool, CreateIndexTool, CollectionSchemaTool, FindTool, @@ -42,4 +45,5 @@ export const MongoDbTools = [ CreateCollectionTool, LogsTool, ExportTool, + ListSearchIndexesTool, ]; diff --git a/src/tools/mongodb/update/renameCollection.ts b/src/tools/mongodb/update/renameCollection.ts index be142e443..4992a3227 100644 --- a/src/tools/mongodb/update/renameCollection.ts +++ b/src/tools/mongodb/update/renameCollection.ts @@ -48,6 +48,7 @@ export class RenameCollectionTool extends MongoDBToolBase { type: "text", }, ], + isError: true, }; case "NamespaceExists": return { @@ -57,6 +58,7 @@ export class RenameCollectionTool extends MongoDBToolBase { type: "text", }, ], + isError: true, }; } } diff --git a/src/tools/tool.ts b/src/tools/tool.ts index fe36619e3..bb7e872c4 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -15,8 +15,38 @@ export type ToolCallbackArgs = Parameters = Parameters>[1]; +export const enum FeatureFlags { + VectorSearch = "vectorSearch", +} + +/** + * The type of operation the tool performs. This is used when evaluating if a tool is allowed to run based on + * the config's `disabledTools` and `readOnly` settings. + * - `metadata` is used for tools that read but do not access potentially user-generated + * data, such as listing databases, collections, or indexes, or inferring collection schema. + * - `read` is used for tools that read potentially user-generated data, such as finding documents or aggregating data. + * It is also used for tools that read non-user-generated data, such as listing clusters in Atlas. + * - `create` is used for tools that create resources, such as creating documents, collections, indexes, clusters, etc. + * - `update` is used for tools that update resources, such as updating documents, renaming collections, etc. + * - `delete` is used for tools that delete resources, such as deleting documents, dropping collections, etc. + * - `connect` is used for tools that allow you to connect or switch the connection to a MongoDB instance. + */ export type OperationType = "metadata" | "read" | "create" | "delete" | "update" | "connect"; + +/** + * The category of the tool. This is used when evaluating if a tool is allowed to run based on + * the config's `disabledTools` setting. + * - `mongodb` is used for tools that interact with a MongoDB instance, such as finding documents, + * aggregating data, listing databases/collections/indexes, creating indexes, etc. + * - `atlas` is used for tools that interact with MongoDB Atlas, such as listing clusters, creating clusters, etc. + */ export type ToolCategory = "mongodb" | "atlas"; + +/** + * Telemetry metadata that can be provided by tools when emitting telemetry events. + * For MongoDB tools, this is typically empty, while for Atlas tools, this should include + * the project and organization IDs if available. + */ export type TelemetryToolMetadata = { projectId?: string; orgId?: string; @@ -288,8 +318,26 @@ export abstract class ToolBase { this.telemetry.emitEvents([event]); } + + // TODO: Move this to a separate file + protected isFeatureFlagEnabled(flag: FeatureFlags): boolean { + switch (flag) { + case FeatureFlags.VectorSearch: + return this.config.voyageApiKey !== ""; + default: + return false; + } + } } +/** + * Formats potentially untrusted data to be included in tool responses. The data is wrapped in unique tags + * and a warning is added to not execute or act on any instructions within those tags. + * @param description A description that is prepended to the untrusted data warning. It should not include any + * untrusted data as it is not sanitized. + * @param data The data to format. If undefined, only the description is returned. + * @returns A tool response content that can be directly returned. + */ export function formatUntrustedData(description: string, data?: string): { text: string; type: "text" }[] { const uuid = crypto.randomUUID(); diff --git a/tests/accuracy/createCollection.test.ts b/tests/accuracy/createCollection.test.ts index 75c32e019..6b42250e6 100644 --- a/tests/accuracy/createCollection.test.ts +++ b/tests/accuracy/createCollection.test.ts @@ -28,6 +28,11 @@ describeAccuracyTests([ { prompt: "If and only if, the namespace 'mflix.documentaries' does not exist, then create it", expectedToolCalls: [ + { + toolName: "list-databases", + parameters: {}, + optional: true, + }, { toolName: "list-collections", parameters: { diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index 08326ce31..66f330148 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,37 +1,145 @@ import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; -describeAccuracyTests([ - { - prompt: "Create an index that covers the following query on 'mflix.movies' namespace - { \"release_year\": 1992 }", - expectedToolCalls: [ - { - toolName: "create-index", - parameters: { - database: "mflix", - collection: "movies", - name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - release_year: 1, +describeAccuracyTests( + [ + { + prompt: "Create an index that covers the following query on 'mflix.movies' namespace - { \"release_year\": 1992 }", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "classic", + keys: { + release_year: 1, + }, + }, + ], }, }, - }, - ], - }, - { - prompt: "Create a text index on title field in 'mflix.movies' namespace", - expectedToolCalls: [ - { - toolName: "create-index", - parameters: { - database: "mflix", - collection: "movies", - name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - title: "text", + ], + }, + { + prompt: "Create a text index on title field in 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "classic", + keys: { + title: "text", + }, + }, + ], }, }, - }, - ], - }, -]); + ], + }, + { + prompt: "Create a vector search index on 'mflix.movies' namespace on the 'plotSummary' field. The index should use 1024 dimensions.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: 1024, + }, + ], + }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mflix.movies' namespace with on the 'plotSummary' field and 'genre' field, both of which contain vector embeddings. Pick a sensible number of dimensions for a voyage 3.5 model.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: Matcher.number( + (value) => value % 2 === 0 && value >= 256 && value <= 8192 + ), + similarity: Matcher.anyOf(Matcher.undefined, Matcher.string()), + }, + { + type: "vector", + path: "genre", + numDimensions: Matcher.number( + (value) => value % 2 === 0 && value >= 256 && value <= 8192 + ), + similarity: Matcher.anyOf(Matcher.undefined, Matcher.string()), + }, + ], + }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mflix.movies' namespace where the 'plotSummary' field is indexed as a 1024-dimensional vector and the 'releaseDate' field is indexed as a regular field.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: 1024, + }, + { + type: "filter", + path: "releaseDate", + }, + ], + }, + ], + }, + }, + ], + }, + ], + { + userConfig: { voyageApiKey: "valid-key" }, + clusterConfig: { + search: true, + }, + } +); diff --git a/tests/accuracy/createIndex.vectorSearchDisabled.test.ts b/tests/accuracy/createIndex.vectorSearchDisabled.test.ts new file mode 100644 index 000000000..eb5fd3ebe --- /dev/null +++ b/tests/accuracy/createIndex.vectorSearchDisabled.test.ts @@ -0,0 +1,57 @@ +/** + * Accuracy tests for when the vector search feature flag is disabled. + * + * TODO: Remove this file once we permanently enable the vector search feature. + */ +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import { Matcher } from "./sdk/matcher.js"; + +describeAccuracyTests( + [ + { + prompt: "(vectorSearchDisabled) Create an index that covers the following query on 'mflix.movies' namespace - { \"release_year\": 1992 }", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: Matcher.anyOf(Matcher.undefined, Matcher.value("classic")), + keys: { + release_year: 1, + }, + }, + ], + }, + }, + ], + }, + { + prompt: "(vectorSearchDisabled) Create a text index on title field in 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: Matcher.anyOf(Matcher.undefined, Matcher.value("classic")), + keys: { + title: "text", + }, + }, + ], + }, + }, + ], + }, + ], + { + userConfig: { voyageApiKey: "" }, + } +); diff --git a/tests/accuracy/dropCollection.test.ts b/tests/accuracy/dropCollection.test.ts index 091a54468..565bef903 100644 --- a/tests/accuracy/dropCollection.test.ts +++ b/tests/accuracy/dropCollection.test.ts @@ -4,6 +4,18 @@ describeAccuracyTests([ { prompt: "Remove mflix.movies namespace from my cluster.", expectedToolCalls: [ + { + toolName: "list-databases", + parameters: {}, + optional: true, + }, + { + toolName: "list-collections", + parameters: { + database: "mflix", + }, + optional: true, + }, { toolName: "drop-collection", parameters: { diff --git a/tests/accuracy/dropDatabase.test.ts b/tests/accuracy/dropDatabase.test.ts index 3010e83ae..f5571486f 100644 --- a/tests/accuracy/dropDatabase.test.ts +++ b/tests/accuracy/dropDatabase.test.ts @@ -4,6 +4,11 @@ describeAccuracyTests([ { prompt: "Remove mflix database from my cluster.", expectedToolCalls: [ + { + toolName: "list-databases", + parameters: {}, + optional: true, + }, { toolName: "drop-database", parameters: { diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts new file mode 100644 index 000000000..82e760756 --- /dev/null +++ b/tests/accuracy/dropIndex.test.ts @@ -0,0 +1,79 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import { Matcher } from "./sdk/matcher.js"; + +// We don't want to delete actual indexes +const mockedTools = { + "drop-index": ({ indexName, database, collection }: Record): CallToolResult => { + return { + content: [ + { + text: `Successfully dropped the index with name "${String(indexName)}" from the provided namespace "${String(database)}.${String(collection)}".`, + type: "text", + }, + ], + }; + }, +} as const; + +describeAccuracyTests([ + { + prompt: "Delete the index called year_1 from mflix.movies namespace", + expectedToolCalls: [ + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: "year_1", + }, + }, + ], + mockedTools, + }, + { + prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + keys: { + title: "text", + }, + type: "classic", + }, + ], + }, + }, + { + toolName: "collection-indexes", + parameters: { + database: "mflix", + collection: "movies", + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + }, + }, + ], + mockedTools, + }, +]); diff --git a/tests/accuracy/export.test.ts b/tests/accuracy/export.test.ts index 6faddc378..534f2ab6e 100644 --- a/tests/accuracy/export.test.ts +++ b/tests/accuracy/export.test.ts @@ -114,12 +114,20 @@ describeAccuracyTests([ arguments: { pipeline: [ { - $group: { - _id: "$release_year", - titles: { - $push: "$title", - }, - }, + $group: Matcher.anyOf( + Matcher.value({ + _id: "$release_year", + titles: { + $push: "$title", + }, + }), + Matcher.value({ + _id: "$release_year", + movies: { + $push: "$title", + }, + }) + ), }, ], }, diff --git a/tests/accuracy/find.test.ts b/tests/accuracy/find.test.ts index 6495912d0..4b2802bbf 100644 --- a/tests/accuracy/find.test.ts +++ b/tests/accuracy/find.test.ts @@ -124,6 +124,7 @@ describeAccuracyTests([ limit: Matcher.anyValue, sort: Matcher.anyValue, }, + optional: true, }, { toolName: "export", @@ -137,7 +138,7 @@ describeAccuracyTests([ arguments: Matcher.anyOf( Matcher.emptyObjectOrUndefined, Matcher.value({ - filter: Matcher.anyValue, + filter: Matcher.emptyObjectOrUndefined, projection: Matcher.anyValue, limit: Matcher.anyValue, sort: Matcher.anyValue, @@ -145,6 +146,11 @@ describeAccuracyTests([ ), }, ], + jsonExportFormat: Matcher.anyOf( + Matcher.undefined, + Matcher.value("relaxed"), + Matcher.value("canonical") + ), }, }, ], diff --git a/tests/accuracy/getPerformanceAdvisor.test.ts b/tests/accuracy/getPerformanceAdvisor.test.ts new file mode 100644 index 000000000..02b61b33f --- /dev/null +++ b/tests/accuracy/getPerformanceAdvisor.test.ts @@ -0,0 +1,135 @@ +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Shared mock tool implementations +const mockedTools = { + "atlas-list-projects": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 project\n\n# | Name | ID\n---|------|----\n1 | mflix | mflix", + }, + ], + }; + }, + "atlas-list-clusters": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 cluster\n\n# | Name | Type | State\n---|------|------|-----\n1 | mflix-cluster | REPLICASET | IDLE", + }, + ], + }; + }, + "atlas-get-performance-advisor": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 2 performance advisor recommendations\n\n## Suggested Indexes\n# | Namespace | Weight | Avg Obj Size | Index Keys\n---|-----------|--------|--------------|------------\n1 | mflix.movies | 0.8 | 1024 | title, year\n2 | mflix.shows | 0.6 | 512 | genre, rating", + }, + ], + }; + }, +}; + +const listProjectsAndClustersToolCalls = [ + { + toolName: "atlas-list-projects", + parameters: {}, + optional: true, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + optional: true, + }, +]; + +describeAccuracyTests([ + // Test for Suggested Indexes operation + { + prompt: "Can you give me index suggestions for the database 'mflix' in the project 'mflix' and cluster 'mflix-cluster'?", + expectedToolCalls: [ + ...listProjectsAndClustersToolCalls, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["suggestedIndexes"], + }, + }, + ], + mockedTools, + }, + // Test for Drop Index Suggestions operation + { + prompt: "Show me drop index suggestions for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + ...listProjectsAndClustersToolCalls, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["dropIndexSuggestions"], + }, + }, + ], + mockedTools, + }, + // Test for Slow Query Logs operation + { + prompt: "Show me the slow query logs for the 'mflix' project and 'mflix-cluster' cluster for the namespaces 'mflix.movies' and 'mflix.shows' since January 1st, 2023", + expectedToolCalls: [ + ...listProjectsAndClustersToolCalls, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["slowQueryLogs"], + namespaces: ["mflix.movies", "mflix.shows"], + since: "2023-01-01T00:00:00Z", + }, + }, + ], + mockedTools, + }, + // Test for Schema Suggestions operation + { + prompt: "Give me schema suggestions for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + ...listProjectsAndClustersToolCalls, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["schemaSuggestions"], + }, + }, + ], + mockedTools, + }, + // Test for all operations + { + prompt: "Show me all performance advisor recommendations for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + ...listProjectsAndClustersToolCalls, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + }, + }, + ], + mockedTools, + }, +]); diff --git a/tests/accuracy/listSearchIndexes.test.ts b/tests/accuracy/listSearchIndexes.test.ts new file mode 100644 index 000000000..6f4a2d1ce --- /dev/null +++ b/tests/accuracy/listSearchIndexes.test.ts @@ -0,0 +1,28 @@ +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; + +describeAccuracyTests([ + { + prompt: "how many search indexes do I have in the collection mydb.mycoll?", + expectedToolCalls: [ + { + toolName: "list-search-indexes", + parameters: { + database: "mydb", + collection: "mycoll", + }, + }, + ], + }, + { + prompt: "which vector search indexes do I have in mydb.mycoll?", + expectedToolCalls: [ + { + toolName: "list-search-indexes", + parameters: { + database: "mydb", + collection: "mycoll", + }, + }, + ], + }, +]); diff --git a/tests/accuracy/sdk/accuracyResultStorage/resultStorage.ts b/tests/accuracy/sdk/accuracyResultStorage/resultStorage.ts index 845af8a04..02f95e795 100644 --- a/tests/accuracy/sdk/accuracyResultStorage/resultStorage.ts +++ b/tests/accuracy/sdk/accuracyResultStorage/resultStorage.ts @@ -4,7 +4,9 @@ export interface LLMToolCall { parameters: Record; } -export type ExpectedToolCall = Omit; +export type ExpectedToolCall = Omit & { + optional?: boolean; +}; export const AccuracyRunStatus = { Done: "done", diff --git a/tests/accuracy/sdk/accuracyScorer.ts b/tests/accuracy/sdk/accuracyScorer.ts index 24a6caf1a..8a1a7000f 100644 --- a/tests/accuracy/sdk/accuracyScorer.ts +++ b/tests/accuracy/sdk/accuracyScorer.ts @@ -81,12 +81,15 @@ export function calculateToolCallingAccuracy( .sort((a, b) => b.score - a.score || a.index - b.index); const bestMatch = candidates[0]; - if (!bestMatch || bestMatch.score === 0) { - return 0; // No matching tool call found, return 0 + if (bestMatch) { + checkedActualToolCallIndexes.add(bestMatch.index); + currentScore = Math.min(currentScore, bestMatch.score); + } else if (expectedCall.optional) { + // Optional expected tool call not found, but it's okay, continue + continue; + } else { + return 0; // Required expected tool call not found, return 0 } - - checkedActualToolCallIndexes.add(bestMatch.index); - currentScore = Math.min(currentScore, bestMatch.score); } return currentScore; diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 692694aa7..6ebed6878 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -6,8 +6,9 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { MCP_SERVER_CLI_SCRIPT } from "./constants.js"; import type { LLMToolCall } from "./accuracyResultStorage/resultStorage.js"; import type { VercelMCPClient, VercelMCPClientTools } from "./agent.js"; +import type { UserConfig } from "../../../src/lib.js"; -type ToolResultGeneratorFn = (...parameters: unknown[]) => CallToolResult | Promise; +type ToolResultGeneratorFn = (parameters: Record) => CallToolResult | Promise; export type MockedTools = Record; /** @@ -44,7 +45,7 @@ export class AccuracyTestingClient { try { const toolResultGeneratorFn = this.mockedTools[toolName]; if (toolResultGeneratorFn) { - return await toolResultGeneratorFn(args); + return await toolResultGeneratorFn(args as Record); } return await tool.execute(args, options); @@ -79,10 +80,19 @@ export class AccuracyTestingClient { this.llmToolCalls = []; } - static async initializeClient(mdbConnectionString: string): Promise { + static async initializeClient( + mdbConnectionString: string, + userConfig: Partial<{ [k in keyof UserConfig]: string }> = {} + ): Promise { + const additionalArgs = Object.entries(userConfig).flatMap(([key, value]) => { + return [`--${key}`, value]; + }); + + const args = [MCP_SERVER_CLI_SCRIPT, "--connectionString", mdbConnectionString, ...additionalArgs]; + const clientTransport = new StdioClientTransport({ command: process.execPath, - args: [MCP_SERVER_CLI_SCRIPT, "--connectionString", mdbConnectionString], + args, }); const client = await createMCPClient({ diff --git a/tests/accuracy/sdk/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index 6617a84f7..adf75d7da 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -10,6 +10,11 @@ import type { AccuracyResultStorage, ExpectedToolCall, LLMToolCall } from "./acc import { getAccuracyResultStorage } from "./accuracyResultStorage/getAccuracyResultStorage.js"; import { getCommitSHA } from "./gitInfo.js"; import type { MongoClient } from "mongodb"; +import type { UserConfig } from "../../../src/lib.js"; +import { + MongoDBClusterProcess, + type MongoClusterConfiguration, +} from "../../integration/tools/mongodb/mongodbClusterProcess.js"; export interface AccuracyTestConfig { /** The prompt to be provided to LLM for evaluation. */ @@ -48,7 +53,13 @@ export interface AccuracyTestConfig { ) => Promise | number; } -export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]): void { +export function describeAccuracyTests( + accuracyTestConfigs: AccuracyTestConfig[], + { + userConfig: partialUserConfig, + clusterConfig, + }: { userConfig?: Partial<{ [k in keyof UserConfig]: string }>; clusterConfig?: MongoClusterConfiguration } = {} +): void { if (!process.env.MDB_ACCURACY_RUN_ID) { throw new Error("MDB_ACCURACY_RUN_ID env variable is required for accuracy test runs!"); } @@ -58,14 +69,23 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) throw new Error("No models available to test. Ensure that the API keys are properly setup!"); } - const eachModel = describe.each(models); + const shouldSkip = clusterConfig && !MongoDBClusterProcess.isConfigurationSupportedInCurrentEnv(clusterConfig); + + const eachModel = describe.skipIf(shouldSkip).each(models); eachModel(`$displayName`, function (model) { const configsWithDescriptions = getConfigsWithDescriptions(accuracyTestConfigs); const accuracyRunId = `${process.env.MDB_ACCURACY_RUN_ID}`; - const mdbIntegration = setupMongoDBIntegrationTest({}, []); + const mdbIntegration = setupMongoDBIntegrationTest(clusterConfig); const { populateTestData, cleanupTestDatabases } = prepareTestData(mdbIntegration); + const userConfig: Partial<{ [k in keyof UserConfig]: string }> = { + apiClientId: process.env.MDB_MCP_API_CLIENT_ID, + apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET, + voyageApiKey: process.env.MDB_VOYAGE_API_KEY, + ...partialUserConfig, + }; + let commitSHA: string; let accuracyResultStorage: AccuracyResultStorage; let testMCPClient: AccuracyTestingClient; @@ -79,7 +99,7 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) commitSHA = retrievedCommitSHA; accuracyResultStorage = getAccuracyResultStorage(); - testMCPClient = await AccuracyTestingClient.initializeClient(mdbIntegration.connectionString()); + testMCPClient = await AccuracyTestingClient.initializeClient(mdbIntegration.connectionString(), userConfig); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/common/connectionManager.oidc.test.ts b/tests/integration/common/connectionManager.oidc.test.ts index de1801a56..3d949bc88 100644 --- a/tests/integration/common/connectionManager.oidc.test.ts +++ b/tests/integration/common/connectionManager.oidc.test.ts @@ -137,15 +137,19 @@ describe.skipIf(process.platform !== "linux")("ConnectionManager OIDC Tests", as addCb?.(oidcIt); }, - () => oidcConfig, - () => ({ - ...setupDriverConfig({ - config: oidcConfig, - defaults: {}, - }), - }), - { enterprise: true, version: mongodbVersion }, - serverArgs + { + getUserConfig: () => oidcConfig, + getDriverOptions: () => + setupDriverConfig({ + config: oidcConfig, + defaults: {}, + }), + downloadOptions: { + runner: true, + downloadOptions: { enterprise: true, version: mongodbVersion }, + serverArgs, + }, + } ); } diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index 0626fd51a..d4664882b 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -1,46 +1,194 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { describe, it, expect } from "vitest"; -import { defaultDriverOptions, type UserConfig } from "../../src/common/config.js"; -import { defaultTestConfig, setupIntegrationTest } from "./helpers.js"; +import { describe, it, expect, afterEach } from "vitest"; +import { type UserConfig } from "../../src/common/config.js"; +import { defaultTestConfig } from "./helpers.js"; import { Elicitation } from "../../src/elicitation.js"; import { createMockElicitInput } from "../utils/elicitationMocks.js"; +import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js"; + +function createTestConfig(config: Partial = {}): UserConfig { + return { + ...defaultTestConfig, + telemetry: "disabled", + // Add fake API credentials so Atlas tools get registered + apiClientId: "test-client-id", + apiClientSecret: "test-client-secret", + ...config, + }; +} describe("Elicitation Integration Tests", () => { - function createTestConfig(config: Partial = {}): UserConfig { - return { - ...defaultTestConfig, - telemetry: "disabled", - // Add fake API credentials so Atlas tools get registered - apiClientId: "test-client-id", - apiClientSecret: "test-client-secret", - ...config, - }; - } - - describe("with elicitation support", () => { - const mockElicitInput = createMockElicitInput(); - const integration = setupIntegrationTest( - () => createTestConfig(), - () => defaultDriverOptions, - { elicitInput: mockElicitInput } - ); - - describe("tools requiring confirmation by default", () => { - it("should request confirmation for drop-database tool and proceed when confirmed", async () => { - mockElicitInput.confirmYes(); + const mockElicitInput = createMockElicitInput(); + afterEach(() => { + mockElicitInput.clear(); + }); + + describeWithMongoDB( + "with elicitation support", + (integration) => { + describe("tools requiring confirmation by default", () => { + it("should request confirmation for drop-database tool and proceed when confirmed", async () => { + mockElicitInput.confirmYes(); + + const result = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to drop the `test-db` database"), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + + // Should attempt to execute (will fail due to no connection, but confirms flow worked) + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("You need to connect to a MongoDB instance"), + }), + ]) + ); + }); + + it("should not proceed when user declines confirmation", async () => { + mockElicitInput.confirmNo(); + + const result = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([ + { + type: "text", + text: "User did not confirm the execution of the `drop-database` tool so the operation was not performed.", + }, + ]); + }); + + it("should request confirmation for drop-collection tool", async () => { + mockElicitInput.confirmYes(); + + await integration.mcpClient().callTool({ + name: "drop-collection", + arguments: { database: "test-db", collection: "test-collection" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to drop the `test-collection` collection"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should request confirmation for delete-many tool", async () => { + mockElicitInput.confirmYes(); + + await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: "test-db", + collection: "test-collection", + filter: { status: "inactive" }, + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to delete documents"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + it("should request confirmation for create-db-user tool", async () => { + mockElicitInput.confirmYes(); + + await integration.mcpClient().callTool({ + name: "atlas-create-db-user", + arguments: { + projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string + username: "test-user", + roles: [{ roleName: "read", databaseName: "test-db" }], + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to create a database user"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should request confirmation for create-access-list tool", async () => { + mockElicitInput.confirmYes(); + + await integration.mcpClient().callTool({ + name: "atlas-create-access-list", + arguments: { + projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string + ipAddresses: ["192.168.1.1"], + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining( + "You are about to add the following entries to the access list" + ), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + }); + + describe("tools not requiring confirmation by default", () => { + it("should not request confirmation for read operations", async () => { + const result = await integration.mcpClient().callTool({ + name: "list-databases", + arguments: {}, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); + }); + + it("should not request confirmation for find operations", async () => { + const result = await integration.mcpClient().callTool({ + name: "find", + arguments: { + database: "test-db", + collection: "test-collection", + }, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); + }); + }); + }, + { + getUserConfig: () => createTestConfig(), + getMockElicitationInput: () => mockElicitInput, + } + ); + + describeWithMongoDB( + "without elicitation support", + (integration) => { + it("should proceed without confirmation for default confirmation-required tools when client lacks elicitation support", async () => { const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to drop the `test-db` database"), - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); - - // Should attempt to execute (will fail due to no connection, but confirms flow worked) + // Note: No mock assertions needed since elicitation is disabled + // Should fail with connection error since we're not connected, but confirms flow bypassed confirmation expect(result.isError).toBe(true); expect(result.content).toEqual( expect.arrayContaining([ @@ -51,265 +199,126 @@ describe("Elicitation Integration Tests", () => { ]) ); }); - - it("should not proceed when user declines confirmation", async () => { - mockElicitInput.confirmNo(); - - const result = await integration.mcpClient().callTool({ - name: "drop-database", - arguments: { database: "test-db" }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(result.isError).toBeFalsy(); - expect(result.content).toEqual([ - { - type: "text", - text: "User did not confirm the execution of the `drop-database` tool so the operation was not performed.", - }, - ]); - }); - - it("should request confirmation for drop-collection tool", async () => { + }, + { + getUserConfig: () => createTestConfig(), + getClientCapabilities: () => ({}), + } + ); + + describeWithMongoDB( + "custom confirmation configuration", + (integration) => { + it("should confirm with a generic message with custom configurations for other tools", async () => { mockElicitInput.confirmYes(); await integration.mcpClient().callTool({ - name: "drop-collection", - arguments: { database: "test-db", collection: "test-collection" }, + name: "list-databases", + arguments: {}, }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to drop the `test-collection` collection"), + message: expect.stringMatching( + /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ + ), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), }); }); - it("should request confirmation for delete-many tool", async () => { - mockElicitInput.confirmYes(); - - await integration.mcpClient().callTool({ - name: "delete-many", - arguments: { - database: "test-db", - collection: "test-collection", - filter: { status: "inactive" }, - }, + it("should not request confirmation when tool is removed from default confirmationRequiredTools", async () => { + const result = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { database: "test-db" }, }); - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to delete documents"), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - }); + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); }); - - it("should request confirmation for create-db-user tool", async () => { + }, + { + getUserConfig: () => createTestConfig({ confirmationRequiredTools: ["list-databases"] }), + getMockElicitationInput: () => mockElicitInput, + } + ); + + describeWithMongoDB( + "confirmation message content validation", + (integration) => { + it("should include specific details in create-db-user confirmation", async () => { mockElicitInput.confirmYes(); await integration.mcpClient().callTool({ name: "atlas-create-db-user", arguments: { projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string - username: "test-user", - roles: [{ roleName: "read", databaseName: "test-db" }], + username: "myuser", + password: "mypassword", + roles: [ + { roleName: "readWrite", databaseName: "mydb" }, + { roleName: "read", databaseName: "logs", collectionName: "events" }, + ], + clusters: ["cluster1", "cluster2"], }, }); - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to create a database user"), + message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), }); }); - it("should request confirmation for create-access-list tool", async () => { + it("should include filter details in delete-many confirmation", async () => { mockElicitInput.confirmYes(); await integration.mcpClient().callTool({ - name: "atlas-create-access-list", + name: "delete-many", arguments: { - projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string - ipAddresses: ["192.168.1.1"], + database: "mydb", + collection: "users", + filter: { status: "inactive", lastLogin: { $lt: "2023-01-01" } }, }, }); - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to add the following entries to the access list"), + message: expect.stringMatching(/mydb.*database/), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), }); }); - }); - - describe("tools not requiring confirmation by default", () => { - it("should not request confirmation for read operations", async () => { - const result = await integration.mcpClient().callTool({ - name: "list-databases", - arguments: {}, - }); - - expect(mockElicitInput.mock).not.toHaveBeenCalled(); - // Should fail with connection error since we're not connected - expect(result.isError).toBe(true); - }); + }, + { + getUserConfig: () => createTestConfig(), + getMockElicitationInput: () => mockElicitInput, + } + ); + + describeWithMongoDB( + "error handling in confirmation flow", + (integration) => { + it("should handle confirmation errors gracefully", async () => { + mockElicitInput.rejectWith(new Error("Confirmation service unavailable")); - it("should not request confirmation for find operations", async () => { const result = await integration.mcpClient().callTool({ - name: "find", - arguments: { - database: "test-db", - collection: "test-collection", - }, + name: "drop-database", + arguments: { database: "test-db" }, }); - expect(mockElicitInput.mock).not.toHaveBeenCalled(); - // Should fail with connection error since we're not connected + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Error running drop-database"), + }), + ]) + ); }); - }); - }); - - describe("without elicitation support", () => { - const integration = setupIntegrationTest( - () => createTestConfig(), - () => defaultDriverOptions, - { getClientCapabilities: () => ({}) } - ); - - it("should proceed without confirmation for default confirmation-required tools when client lacks elicitation support", async () => { - const result = await integration.mcpClient().callTool({ - name: "drop-database", - arguments: { database: "test-db" }, - }); - - // Note: No mock assertions needed since elicitation is disabled - // Should fail with connection error since we're not connected, but confirms flow bypassed confirmation - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("You need to connect to a MongoDB instance"), - }), - ]) - ); - }); - }); - - describe("custom confirmation configuration", () => { - const mockElicitInput = createMockElicitInput(); - const integration = setupIntegrationTest( - () => createTestConfig({ confirmationRequiredTools: ["list-databases"] }), - () => defaultDriverOptions, - { elicitInput: mockElicitInput } - ); - - it("should confirm with a generic message with custom configurations for other tools", async () => { - mockElicitInput.confirmYes(); - - await integration.mcpClient().callTool({ - name: "list-databases", - arguments: {}, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching( - /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ - ), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - }); - }); - - it("should not request confirmation when tool is removed from default confirmationRequiredTools", async () => { - const result = await integration.mcpClient().callTool({ - name: "drop-database", - arguments: { database: "test-db" }, - }); - - expect(mockElicitInput.mock).not.toHaveBeenCalled(); - // Should fail with connection error since we're not connected - expect(result.isError).toBe(true); - }); - }); - - describe("confirmation message content validation", () => { - const mockElicitInput = createMockElicitInput(); - const integration = setupIntegrationTest( - () => createTestConfig(), - () => defaultDriverOptions, - { elicitInput: mockElicitInput } - ); - - it("should include specific details in create-db-user confirmation", async () => { - mockElicitInput.confirmYes(); - - await integration.mcpClient().callTool({ - name: "atlas-create-db-user", - arguments: { - projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string - username: "myuser", - password: "mypassword", - roles: [ - { roleName: "readWrite", databaseName: "mydb" }, - { roleName: "read", databaseName: "logs", collectionName: "events" }, - ], - clusters: ["cluster1", "cluster2"], - }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - }); - }); - - it("should include filter details in delete-many confirmation", async () => { - mockElicitInput.confirmYes(); - - await integration.mcpClient().callTool({ - name: "delete-many", - arguments: { - database: "mydb", - collection: "users", - filter: { status: "inactive", lastLogin: { $lt: "2023-01-01" } }, - }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/mydb.*database/), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - }); - }); - }); - - describe("error handling in confirmation flow", () => { - const mockElicitInput = createMockElicitInput(); - const integration = setupIntegrationTest( - () => createTestConfig(), - () => defaultDriverOptions, - { elicitInput: mockElicitInput } - ); - - it("should handle confirmation errors gracefully", async () => { - mockElicitInput.rejectWith(new Error("Confirmation service unavailable")); - - const result = await integration.mcpClient().callTool({ - name: "drop-database", - arguments: { database: "test-db" }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Error running drop-database"), - }), - ]) - ); - }); - }); + }, + { + getUserConfig: () => createTestConfig(), + getMockElicitationInput: () => mockElicitInput, + } + ); }); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index d62354a83..bde3c622a 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -29,13 +29,22 @@ export const driverOptions = setupDriverConfig({ export const defaultDriverOptions: DriverOptions = { ...driverOptions }; -interface ParameterInfo { +interface Parameter { name: string; - type: string; description: string; required: boolean; } +interface SingleValueParameter extends Parameter { + type: string; +} + +interface AnyOfParameter extends Parameter { + anyOf: { type: string }[]; +} + +type ParameterInfo = SingleValueParameter | AnyOfParameter; + type ToolInfo = Awaited>["tools"][number]; export interface IntegrationTest { @@ -48,6 +57,8 @@ export const defaultTestConfig: UserConfig = { loggers: ["stderr"], }; +export const DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS = 1_200_000; + export function setupIntegrationTest( getUserConfig: () => UserConfig, getDriverOptions: () => DriverOptions, @@ -217,18 +228,38 @@ export function getParameters(tool: ToolInfo): ParameterInfo[] { return Object.entries(tool.inputSchema.properties) .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => { - expect(value).toHaveProperty("type"); + .map(([name, value]) => { expect(value).toHaveProperty("description"); - const typedValue = value as { type: string; description: string }; - expect(typeof typedValue.type).toBe("string"); - expect(typeof typedValue.description).toBe("string"); + const description = (value as { description: string }).description; + const required = (tool.inputSchema.required as string[])?.includes(name) ?? false; + expect(typeof description).toBe("string"); + + if (value && typeof value === "object" && "anyOf" in value) { + const typedOptions = new Array<{ type: string }>(); + for (const option of value.anyOf as { type: string }[]) { + expect(option).toHaveProperty("type"); + + typedOptions.push({ type: option.type }); + } + + return { + name, + anyOf: typedOptions, + description: description, + required, + }; + } + + expect(value).toHaveProperty("type"); + + const type = (value as { type: string }).type; + expect(typeof type).toBe("string"); return { - name: key, - type: typedValue.type, - description: typedValue.description, - required: (tool.inputSchema.required as string[])?.includes(key) ?? false, + name, + type, + description, + required, }; }); } @@ -382,3 +413,7 @@ export function getDataFromUntrustedContent(content: string): string { } return match.groups.data.trim(); } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/integration/indexCheck.test.ts b/tests/integration/indexCheck.test.ts index 438cd86fe..b99209201 100644 --- a/tests/integration/indexCheck.test.ts +++ b/tests/integration/indexCheck.test.ts @@ -313,10 +313,12 @@ describe("IndexCheck integration tests", () => { }); }); }, - () => ({ - ...defaultTestConfig, - indexCheck: true, // Enable indexCheck - }) + { + getUserConfig: () => ({ + ...defaultTestConfig, + indexCheck: true, // Enable indexCheck + }), + } ); }); @@ -424,10 +426,12 @@ describe("IndexCheck integration tests", () => { expect(content).not.toContain("Index check failed"); }); }, - () => ({ - ...defaultTestConfig, - indexCheck: false, // Disable indexCheck - }) + { + getUserConfig: () => ({ + ...defaultTestConfig, + indexCheck: false, // Disable indexCheck + }), + } ); }); @@ -456,10 +460,12 @@ describe("IndexCheck integration tests", () => { expect(response.isError).toBeFalsy(); }); }, - () => ({ - ...defaultTestConfig, - // indexCheck not specified, should default to false - }) + { + getUserConfig: () => ({ + ...defaultTestConfig, + // indexCheck not specified, should default to false + }), + } ); }); }); diff --git a/tests/integration/resources/exportedData.test.ts b/tests/integration/resources/exportedData.test.ts index 6e361bf03..5903fb23d 100644 --- a/tests/integration/resources/exportedData.test.ts +++ b/tests/integration/resources/exportedData.test.ts @@ -173,5 +173,7 @@ describeWithMongoDB( }); }); }, - () => userConfig + { + getUserConfig: () => userConfig, + } ); diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index ef98075a7..090df4a53 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,4 +1,4 @@ -import { defaultDriverOptions, defaultTestConfig, expectDefined, setupIntegrationTest } from "./helpers.js"; +import { defaultTestConfig, expectDefined } from "./helpers.js"; import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js"; import { describe, expect, it } from "vitest"; @@ -15,81 +15,84 @@ describe("Server integration test", () => { expect(atlasTools.length).toBeLessThanOrEqual(0); }); }, - () => ({ - ...defaultTestConfig, - apiClientId: undefined, - apiClientSecret: undefined, - }), - () => defaultDriverOptions + { + getUserConfig: () => ({ + ...defaultTestConfig, + apiClientId: undefined, + apiClientSecret: undefined, + }), + } ); - describe("with atlas", () => { - const integration = setupIntegrationTest( - () => ({ + describeWithMongoDB( + "with atlas", + (integration) => { + describe("list capabilities", () => { + it("should return positive number of tools and have some atlas tools", async () => { + const tools = await integration.mcpClient().listTools(); + expectDefined(tools); + expect(tools.tools.length).toBeGreaterThan(0); + + const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-")); + expect(atlasTools.length).toBeGreaterThan(0); + }); + + it("should return no prompts", async () => { + await expect(() => integration.mcpClient().listPrompts()).rejects.toMatchObject({ + message: "MCP error -32601: Method not found", + }); + }); + + it("should return capabilities", () => { + const capabilities = integration.mcpClient().getServerCapabilities(); + expectDefined(capabilities); + expectDefined(capabilities?.logging); + expectDefined(capabilities?.completions); + expectDefined(capabilities?.tools); + expectDefined(capabilities?.resources); + expect(capabilities.experimental).toBeUndefined(); + expect(capabilities.prompts).toBeUndefined(); + }); + }); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, apiClientId: "test", apiClientSecret: "test", }), - () => defaultDriverOptions - ); + } + ); - describe("list capabilities", () => { - it("should return positive number of tools and have some atlas tools", async () => { + describeWithMongoDB( + "with read-only mode", + (integration) => { + it("should only register read and metadata operation tools when read-only mode is enabled", async () => { const tools = await integration.mcpClient().listTools(); expectDefined(tools); expect(tools.tools.length).toBeGreaterThan(0); - const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-")); - expect(atlasTools.length).toBeGreaterThan(0); - }); - - it("should return no prompts", async () => { - await expect(() => integration.mcpClient().listPrompts()).rejects.toMatchObject({ - message: "MCP error -32601: Method not found", - }); - }); + // Check that we have some tools available (the read and metadata ones) + expect(tools.tools.some((tool) => tool.name === "find")).toBe(true); + expect(tools.tools.some((tool) => tool.name === "collection-schema")).toBe(true); + expect(tools.tools.some((tool) => tool.name === "list-databases")).toBe(true); + expect(tools.tools.some((tool) => tool.name === "atlas-list-orgs")).toBe(true); + expect(tools.tools.some((tool) => tool.name === "atlas-list-projects")).toBe(true); - it("should return capabilities", () => { - const capabilities = integration.mcpClient().getServerCapabilities(); - expectDefined(capabilities); - expectDefined(capabilities?.logging); - expectDefined(capabilities?.completions); - expectDefined(capabilities?.tools); - expectDefined(capabilities?.resources); - expect(capabilities.experimental).toBeUndefined(); - expect(capabilities.prompts).toBeUndefined(); + // Check that non-read tools are NOT available + expect(tools.tools.some((tool) => tool.name === "insert-one")).toBe(false); + expect(tools.tools.some((tool) => tool.name === "update-many")).toBe(false); + expect(tools.tools.some((tool) => tool.name === "delete-one")).toBe(false); + expect(tools.tools.some((tool) => tool.name === "drop-collection")).toBe(false); }); - }); - }); - - describe("with read-only mode", () => { - const integration = setupIntegrationTest( - () => ({ + }, + { + getUserConfig: () => ({ ...defaultTestConfig, readOnly: true, apiClientId: "test", apiClientSecret: "test", }), - () => defaultDriverOptions - ); - - it("should only register read and metadata operation tools when read-only mode is enabled", async () => { - const tools = await integration.mcpClient().listTools(); - expectDefined(tools); - expect(tools.tools.length).toBeGreaterThan(0); - - // Check that we have some tools available (the read and metadata ones) - expect(tools.tools.some((tool) => tool.name === "find")).toBe(true); - expect(tools.tools.some((tool) => tool.name === "collection-schema")).toBe(true); - expect(tools.tools.some((tool) => tool.name === "list-databases")).toBe(true); - expect(tools.tools.some((tool) => tool.name === "atlas-list-orgs")).toBe(true); - expect(tools.tools.some((tool) => tool.name === "atlas-list-projects")).toBe(true); - - // Check that non-read tools are NOT available - expect(tools.tools.some((tool) => tool.name === "insert-one")).toBe(false); - expect(tools.tools.some((tool) => tool.name === "update-many")).toBe(false); - expect(tools.tools.some((tool) => tool.name === "delete-one")).toBe(false); - expect(tools.tools.some((tool) => tool.name === "drop-collection")).toBe(false); - }); - }); + } + ); }); diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 13f160c79..2a8fce123 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -1,10 +1,11 @@ import { ObjectId } from "mongodb"; -import type { Group } from "../../../../src/common/atlas/openapi.js"; +import type { ClusterDescription20240805, Group } from "../../../../src/common/atlas/openapi.js"; import type { ApiClient } from "../../../../src/common/atlas/apiClient.js"; import type { IntegrationTest } from "../../helpers.js"; import { setupIntegrationTest, defaultTestConfig, defaultDriverOptions } from "../../helpers.js"; import type { SuiteCollector } from "vitest"; import { afterAll, beforeAll, describe } from "vitest"; +import type { Session } from "../../../../src/common/session.js"; export type IntegrationTestFunction = (integration: IntegrationTest) => void; @@ -17,8 +18,8 @@ export function describeWithAtlas(name: string, fn: IntegrationTestFunction): vo const integration = setupIntegrationTest( () => ({ ...defaultTestConfig, - apiClientId: process.env.MDB_MCP_API_CLIENT_ID, - apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET, + apiClientId: process.env.MDB_MCP_API_CLIENT_ID || "test-client", + apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET || "test-secret", apiBaseUrl: process.env.MDB_MCP_API_BASE_URL ?? "https://cloud-dev.mongodb.com", }), () => defaultDriverOptions @@ -34,6 +35,16 @@ interface ProjectTestArgs { type ProjectTestFunction = (args: ProjectTestArgs) => void; +export function withCredentials(integration: IntegrationTest, fn: IntegrationTestFunction): SuiteCollector { + const describeFn = + !process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length + ? describe.skip + : describe; + return describeFn("with credentials", () => { + fn(integration); + }); +} + export function withProject(integration: IntegrationTest, fn: ProjectTestFunction): SuiteCollector { return describe("with project", () => { let projectId: string = ""; @@ -150,3 +161,91 @@ async function createProject(apiClient: ApiClient): Promise>; } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function assertClusterIsAvailable( + session: Session, + projectId: string, + clusterName: string +): Promise { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return true; + } catch { + return false; + } +} + +export async function deleteCluster( + session: Session, + projectId: string, + clusterName: string, + shouldWaitTillClusterIsDeleted: boolean = false +): Promise { + await session.apiClient.deleteCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + if (!shouldWaitTillClusterIsDeleted) { + return; + } + + while (true) { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + await sleep(1000); + } catch { + break; + } + } +} + +export async function waitCluster( + session: Session, + projectId: string, + clusterName: string, + check: (cluster: ClusterDescription20240805) => boolean | Promise, + pollingInterval: number = 1000, + maxPollingIterations: number = 300 +): Promise { + for (let i = 0; i < maxPollingIterations; i++) { + const cluster = await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (await check(cluster)) { + return; + } + await sleep(pollingInterval); + } + + throw new Error( + `Cluster wait timeout: ${clusterName} did not meet condition within ${maxPollingIterations} iterations` + ); +} diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 0e666be7d..543988c47 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,71 +1,16 @@ import type { Session } from "../../../../src/common/session.js"; import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js"; -import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.js"; +import { + describeWithAtlas, + withProject, + randomId, + parseTable, + deleteCluster, + waitCluster, + sleep, +} from "./atlasHelpers.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function deleteCluster( - session: Session, - projectId: string, - clusterName: string, - wait: boolean = false -): Promise { - await session.apiClient.deleteCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - - if (!wait) { - return; - } - - while (true) { - try { - await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - await sleep(1000); - } catch { - break; - } - } -} - -async function waitCluster( - session: Session, - projectId: string, - clusterName: string, - check: (cluster: ClusterDescription20240805) => boolean | Promise -): Promise { - while (true) { - const cluster = await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - if (await check(cluster)) { - return; - } - await sleep(1000); - } -} - describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId, getIpAddress }) => { const clusterName = "ClusterTest-" + randomId; diff --git a/tests/integration/tools/atlas/orgs.test.ts b/tests/integration/tools/atlas/orgs.test.ts index 72e0182bf..baa4f96a9 100644 --- a/tests/integration/tools/atlas/orgs.test.ts +++ b/tests/integration/tools/atlas/orgs.test.ts @@ -1,23 +1,25 @@ import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; -import { parseTable, describeWithAtlas } from "./atlasHelpers.js"; +import { parseTable, describeWithAtlas, withCredentials } from "./atlasHelpers.js"; import { describe, expect, it } from "vitest"; describeWithAtlas("orgs", (integration) => { - describe("atlas-list-orgs", () => { - it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const listOrgs = tools.find((tool) => tool.name === "atlas-list-orgs"); - expectDefined(listOrgs); - }); + withCredentials(integration, () => { + describe("atlas-list-orgs", () => { + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const listOrgs = tools.find((tool) => tool.name === "atlas-list-orgs"); + expectDefined(listOrgs); + }); - it("returns org names", async () => { - const response = await integration.mcpClient().callTool({ name: "atlas-list-orgs", arguments: {} }); - const elements = getResponseElements(response); - expect(elements[0]?.text).toContain("Found 1 organizations"); - expect(elements[1]?.text).toContain(" { + const response = await integration.mcpClient().callTool({ name: "atlas-list-orgs", arguments: {} }); + const elements = getResponseElements(response); + expect(elements[0]?.text).toContain("Found 1 organizations"); + expect(elements[1]?.text).toContain("10 minutes) because we provision a real M10 cluster, which can take up to 10 minutes to provision. +// The timeouts for the beforeAll/afterAll hooks have been modified to account for longer running tests. + +import type { Session } from "../../../../src/common/session.js"; +import { DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS, expectDefined, getResponseElements } from "../../helpers.js"; +import { describeWithAtlas, withProject, randomId, waitCluster, deleteCluster } from "./atlasHelpers.js"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +describeWithAtlas("performanceAdvisor", (integration) => { + withProject(integration, ({ getProjectId }) => { + const clusterName = "ClusterTest-" + randomId; + + afterAll(async () => { + const projectId = getProjectId(); + if (projectId) { + const session: Session = integration.mcpServer().session; + await deleteCluster(session, projectId, clusterName); + } + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + describe("atlas-get-performance-advisor", () => { + beforeAll(async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.createCluster({ + params: { + path: { + groupId: projectId, + }, + }, + body: { + name: clusterName, + clusterType: "REPLICASET", + backupEnabled: true, + configServerManagementMode: "ATLAS_MANAGED", + diskWarmingMode: "FULLY_WARMED", + replicaSetScalingStrategy: "WORKLOAD_TYPE", + rootCertType: "ISRGROOTX1", + terminationProtectionEnabled: false, + versionReleaseSystem: "LTS", + replicationSpecs: [ + { + zoneName: "Zone 1", + regionConfigs: [ + { + providerName: "AWS", + regionName: "US_EAST_1", + electableSpecs: { instanceSize: "M10", nodeCount: 3 }, + priority: 7, + }, + ], + }, + ], + }, + }); + + await waitCluster( + session, + projectId, + clusterName, + (cluster) => { + return cluster.stateName === "IDLE"; + }, + 10000, + 120 + ); + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const getPerformanceAdvisor = tools.find((tool) => tool.name === "atlas-get-performance-advisor"); + expectDefined(getPerformanceAdvisor); + expect(getPerformanceAdvisor.inputSchema.type).toBe("object"); + expectDefined(getPerformanceAdvisor.inputSchema.properties); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("projectId"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("clusterName"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("operations"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("since"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("namespaces"); + }); + + it("returns performance advisor data from a paid tier cluster", async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName, + operations: ["suggestedIndexes", "dropIndexSuggestions", "schemaSuggestions"], + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain(" { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + // Mock the API client methods since we can't guarantee performance advisor data + const mockSuggestedIndexes = vi.fn().mockResolvedValue({ + content: { + suggestedIndexes: [ + { + namespace: "testdb.testcollection", + index: { field: 1 }, + impact: ["queryShapeString"], + }, + ], + }, + }); + + const mockDropIndexSuggestions = vi.fn().mockResolvedValue({ + content: { + hiddenIndexes: [], + redundantIndexes: [ + { + accessCount: 100, + namespace: "testdb.testcollection", + index: { field: 1 }, + reason: "Redundant with compound index", + }, + ], + unusedIndexes: [], + }, + }); + + const mockSchemaAdvice = vi.fn().mockResolvedValue({ + content: { + recommendations: [ + { + description: "Consider adding an index on 'status' field", + recommendation: "REDUCE_LOOKUP_OPS", + affectedNamespaces: [ + { + namespace: "testdb.testcollection", + triggers: [ + { + triggerType: "PERCENT_QUERIES_USE_LOOKUP", + details: + "Queries filtering by status field are causing collection scans", + }, + ], + }, + ], + }, + ], + }, + }); + + const mockSlowQueries = vi.fn().mockResolvedValue({ + slowQueries: [ + { + namespace: "testdb.testcollection", + query: { find: "testcollection", filter: { status: "active" } }, + duration: 1500, + timestamp: "2024-01-15T10:30:00Z", + }, + ], + }); + + const mockGetCluster = vi.fn().mockResolvedValue({ + connectionStrings: { + standard: "mongodb://test-cluster.mongodb.net:27017", + }, + }); + + session.apiClient.listClusterSuggestedIndexes = mockSuggestedIndexes; + session.apiClient.listDropIndexes = mockDropIndexSuggestions; + session.apiClient.listSchemaAdvice = mockSchemaAdvice; + session.apiClient.listSlowQueries = mockSlowQueries; + session.apiClient.getCluster = mockGetCluster; + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName: "mockClusterName", + operations: ["suggestedIndexes", "dropIndexSuggestions", "slowQueryLogs", "schemaSuggestions"], + }, + }); + + if (response.isError) { + console.error("Performance advisor call failed:", response.content); + throw new Error("Performance advisor call failed - see console for details"); + } + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain(" ({ - ...config, - connectionString: mdbIntegration.connectionString(), - }) + { + getUserConfig: (mdbIntegration) => ({ + ...defaultTestConfig, + connectionString: mdbIntegration.connectionString(), + }), + } ); -describeWithMongoDB( - "Connect tool", - (integration) => { - validateToolMetadata( - integration, - "connect", - "Connect to a MongoDB instance. The config resource captures if the server is already connected to a MongoDB cluster. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new MongoDB cluster.", - [ - { - name: "connectionString", - description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format)", - type: "string", - required: true, - }, - ] - ); +describeWithMongoDB("Connect tool", (integration) => { + validateToolMetadata( + integration, + "connect", + "Connect to a MongoDB instance. The config resource captures if the server is already connected to a MongoDB cluster. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new MongoDB cluster.", + [ + { + name: "connectionString", + description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format)", + type: "string", + required: true, + }, + ] + ); - validateThrowsForInvalidArguments(integration, "connect", [{}, { connectionString: 123 }]); + validateThrowsForInvalidArguments(integration, "connect", [{}, { connectionString: 123 }]); - it("doesn't have the switch-connection tool registered", async () => { - const { tools } = await integration.mcpClient().listTools(); - const tool = tools.find((tool) => tool.name === "switch-connection"); - expect(tool).toBeUndefined(); - }); + it("doesn't have the switch-connection tool registered", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((tool) => tool.name === "switch-connection"); + expect(tool).toBeUndefined(); + }); - describe("with connection string", () => { - it("connects to the database", async () => { - const response = await integration.mcpClient().callTool({ - name: "connect", - arguments: { - connectionString: integration.connectionString(), - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("Successfully connected"); + describe("with connection string", () => { + it("connects to the database", async () => { + const response = await integration.mcpClient().callTool({ + name: "connect", + arguments: { + connectionString: integration.connectionString(), + }, }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully connected"); }); + }); - describe("with invalid connection string", () => { - it("returns error message", async () => { - const response = await integration.mcpClient().callTool({ - name: "connect", - arguments: { connectionString: "mangodb://localhost:12345" }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("The configured connection string is not valid."); + describe("with invalid connection string", () => { + it("returns error message", async () => { + const response = await integration.mcpClient().callTool({ + name: "connect", + arguments: { connectionString: "mangodb://localhost:12345" }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("The configured connection string is not valid."); - // Should not suggest using the config connection string (because we don't have one) - expect(content).not.toContain("Your config lists a different connection string"); + // Should not suggest using the config connection string (because we don't have one) + expect(content).not.toContain("Your config lists a different connection string"); + }); + }); +}); + +describeWithMongoDB( + "Connect tool when disabled", + (integration) => { + it("is not suggested when querying MongoDB disconnected", async () => { + const response = await integration.mcpClient().callTool({ + name: "find", + arguments: { database: "some-db", collection: "some-collection" }, }); + + const elements = getResponseElements(response); + expect(elements).toHaveLength(2); + expect(elements[0]?.text).toContain( + "You need to connect to a MongoDB instance before you can access its data." + ); + expect(elements[1]?.text).toContain( + "There are no tools available to connect. Please update the configuration to include a connection string and restart the server." + ); }); }, - () => config -); - -describe("Connect tool when disabled", () => { - const integration = setupIntegrationTest( - () => ({ + { + getUserConfig: () => ({ ...defaultTestConfig, disabledTools: ["connect"], }), - () => defaultDriverOptions - ); - - it("is not suggested when querying MongoDB disconnected", async () => { - const response = await integration.mcpClient().callTool({ - name: "find", - arguments: { database: "some-db", collection: "some-collection" }, - }); - - const elements = getResponseElements(response); - expect(elements).toHaveLength(2); - expect(elements[0]?.text).toContain( - "You need to connect to a MongoDB instance before you can access its data." - ); - expect(elements[1]?.text).toContain( - "There are no tools available to connect. Please update the configuration to include a connection string and restart the server." - ); - }); -}); + } +); diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 3c789be83..ae41869ea 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -1,4 +1,4 @@ -import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior, waitUntilSearchIsReady } from "../mongodbHelpers.js"; import { getResponseContent, @@ -6,199 +6,507 @@ import { validateToolMetadata, validateThrowsForInvalidArguments, expectDefined, + defaultTestConfig, } from "../../../helpers.js"; -import type { IndexDirection } from "mongodb"; -import { expect, it } from "vitest"; - -describeWithMongoDB("createIndex tool", (integration) => { - validateToolMetadata(integration, "create-index", "Create an index for a collection", [ - ...databaseCollectionParameters, - { - name: "keys", - type: "object", - description: "The index definition", - required: true, - }, - { - name: "name", - type: "string", - description: "The name of the index", - required: false, - }, - ]); - - validateThrowsForInvalidArguments(integration, "create-index", [ - {}, - { collection: "bar", database: 123, keys: { foo: 1 } }, - { collection: [], database: "test", keys: { foo: 1 } }, - { collection: "bar", database: "test", keys: { foo: 1 }, name: 123 }, - { collection: "bar", database: "test", keys: "foo", name: "my-index" }, - ]); - - const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { - const mongoClient = integration.mongoClient(); - const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); - expect(collections).toHaveLength(1); - expect(collections[0]?.name).toEqual("coll1"); - const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); - expect(indexes).toHaveLength(expected.length + 1); - expect(indexes[0]?.name).toEqual("_id_"); - for (const index of expected) { - const foundIndex = indexes.find((i) => i.name === index.name); - expectDefined(foundIndex); - expect(foundIndex.key).toEqual(index.key); - } - }; - - it("creates the namespace if necessary", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - keys: { prop1: 1 }, - name: "my-index", - }, - }); +import { ObjectId, type IndexDirection } from "mongodb"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` - ); +describeWithMongoDB("createIndex tool when search is not enabled", (integration) => { + it("doesn't allow creating vector search indexes", async () => { + expect(integration.mcpServer().userConfig.voyageApiKey).toEqual(""); - await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); - }); + const { tools } = await integration.mcpClient().listTools(); + const createIndexTool = tools.find((tool) => tool.name === "create-index"); + const definitionProperty = createIndexTool?.inputSchema.properties?.definition as { + type: string; + items: { anyOf: Array<{ properties: Record> }> }; + }; + expectDefined(definitionProperty); - it("generates a name if not provided", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); + expect(definitionProperty.type).toEqual("array"); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); + // Because search is not enabled, the only available index definition is 'classic' + // We expect 1 option in the anyOf array where type is "classic" + expect(definitionProperty.items.anyOf).toHaveLength(1); + expect(definitionProperty.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "classic" }); + expect(definitionProperty.items.anyOf?.[0]?.properties?.keys).toBeDefined(); }); +}); - it("can create multiple indexes in the same collection", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); +describeWithMongoDB( + "createIndex tool when search is enabled", + (integration) => { + it("allows creating vector search indexes", async () => { + expect(integration.mcpServer().userConfig.voyageApiKey).not.toEqual(""); + + const { tools } = await integration.mcpClient().listTools(); + const createIndexTool = tools.find((tool) => tool.name === "create-index"); + const definitionProperty = createIndexTool?.inputSchema.properties?.definition as { + type: string; + items: { anyOf: Array<{ properties: Record> }> }; + }; + expectDefined(definitionProperty); + + expect(definitionProperty.type).toEqual("array"); + + // Because search is now enabled, we should see both "classic" and "vectorSearch" options in + // the anyOf array. + expect(definitionProperty.items.anyOf).toHaveLength(2); + expect(definitionProperty.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "classic" }); + expect(definitionProperty.items.anyOf?.[0]?.properties?.keys).toBeDefined(); + expect(definitionProperty.items.anyOf?.[1]?.properties?.type).toEqual({ + type: "string", + const: "vectorSearch", + }); + expect(definitionProperty.items.anyOf?.[1]?.properties?.fields).toBeDefined(); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + const fields = definitionProperty.items.anyOf?.[1]?.properties?.fields as { + type: string; + items: { anyOf: Array<{ type: string; properties: Record> }> }; + }; - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop2: -1 } }, + expect(fields.type).toEqual("array"); + expect(fields.items.anyOf).toHaveLength(2); + expect(fields.items.anyOf?.[0]?.type).toEqual("object"); + expect(fields.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "filter" }); + expectDefined(fields.items.anyOf?.[0]?.properties?.path); + + expect(fields.items.anyOf?.[1]?.type).toEqual("object"); + expect(fields.items.anyOf?.[1]?.properties?.type).toEqual({ type: "string", const: "vector" }); + expectDefined(fields.items.anyOf?.[1]?.properties?.path); + expectDefined(fields.items.anyOf?.[1]?.properties?.quantization); + expectDefined(fields.items.anyOf?.[1]?.properties?.numDimensions); + expectDefined(fields.items.anyOf?.[1]?.properties?.similarity); }); + }, + { + getUserConfig: () => { + return { + ...defaultTestConfig, + voyageApiKey: "valid_key", + }; + }, + } +); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); +describeWithMongoDB( + "createIndex tool with classic indexes", + (integration) => { + validateToolMetadata(integration, "create-index", "Create an index for a collection", [ + ...databaseCollectionParameters, + { + name: "definition", + type: "array", + description: + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes", + required: true, + }, + { + name: "name", + type: "string", + description: "The name of the index", + required: false, + }, + ]); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop2_-1", key: { prop2: -1 } }, + validateThrowsForInvalidArguments(integration, "create-index", [ + {}, + { collection: "bar", database: 123, definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: [], database: "test", definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: "bar", database: "test", definition: [{ type: "classic", keys: { foo: 1 } }], name: 123 }, + { + collection: "bar", + database: "test", + definition: [{ type: "unknown", keys: { foo: 1 } }], + name: "my-index", + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: { foo: 1 } }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "vector", path: true }] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "filter", path: "foo" }] }], + }, + { + collection: "bar", + database: "test", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "foo", numDimensions: 128 }, + { type: "filter", path: "bar", numDimensions: 128 }, + ], + }, + ], + }, ]); - }); - it("can create multiple indexes on the same property", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, + const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { + const mongoClient = integration.mongoClient(); + const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0]?.name).toEqual("coll1"); + const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); + expect(indexes).toHaveLength(expected.length + 1); + expect(indexes[0]?.name).toEqual("_id_"); + for (const index of expected) { + const foundIndex = indexes.find((i) => i.name === index.name); + expectDefined(foundIndex); + expect(foundIndex.key).toEqual(index.key); + } + }; + + it("creates the namespace if necessary", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [ + { + type: "classic", + keys: { prop1: 1 }, + }, + ], + name: "my-index", + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}".` + ); + + await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("generates a name if not provided", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: -1 } }, + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".` + ); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes in the same collection", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop1_-1", key: { prop1: -1 } }, - ]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".` + ); - it("doesn't duplicate indexes", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop2: -1 } }], + }, + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}".` + ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop2_-1", key: { prop2: -1 } }, + ]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes on the same property", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".` + ); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: -1 } }], + }, + }); - const testCases: { name: string; direction: IndexDirection }[] = [ - { name: "descending", direction: -1 }, - { name: "ascending", direction: 1 }, - { name: "hashed", direction: "hashed" }, - { name: "text", direction: "text" }, - { name: "geoHaystack", direction: "2dsphere" }, - { name: "geo2d", direction: "2d" }, - ]; - - for (const { name, direction } of testCases) { - it(`creates ${name} index`, async () => { + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}".` + ); + + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop1_-1", key: { prop1: -1 } }, + ]); + }); + + it("doesn't duplicate indexes", async () => { await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ + let response = await integration.mcpClient().callTool({ name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: direction } }, + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, }); expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".` ); - let expectedKey: object = { prop1: direction }; - if (direction === "text") { - expectedKey = { - _fts: "text", - _ftsx: 1, - }; - } - await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".` + ); + + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); + + it("fails to create a vector search index", async () => { + await integration.connectMcpClient(); + const collection = new ObjectId().toString(); + await integration + .mcpServer() + .session.serviceProvider.createCollection(integration.randomDbName(), collection); + + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection, + name: "vector_1_vector", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain("The connected MongoDB deployment does not support vector search indexes."); + expect(response.isError).toBe(true); + }); + + const testCases: { name: string; direction: IndexDirection }[] = [ + { name: "descending", direction: -1 }, + { name: "ascending", direction: 1 }, + { name: "hashed", direction: "hashed" }, + { name: "text", direction: "text" }, + { name: "geoHaystack", direction: "2dsphere" }, + { name: "geo2d", direction: "2d" }, + ]; + + for (const { name, direction } of testCases) { + it(`creates ${name} index`, async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: direction } }], + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}".` + ); + + let expectedKey: object = { prop1: direction }; + if (direction === "text") { + expectedKey = { + _fts: "text", + _ftsx: 1, + }; + } + await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + }); + } + + validateAutoConnectBehavior(integration, "create-index", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}".`, + }; + }); + }, + { + getUserConfig: () => { + return { + ...defaultTestConfig, + voyageApiKey: "valid_key", + }; + }, } +); - validateAutoConnectBehavior(integration, "create-index", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - keys: { prop1: 1 }, - }, - expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, - }; - }); -}); +describeWithMongoDB( + "createIndex tool with vector search indexes", + (integration) => { + let provider: NodeDriverServiceProvider; + + beforeEach(async ({ signal }) => { + await integration.connectMcpClient(); + provider = integration.mcpServer().session.serviceProvider; + await waitUntilSearchIsReady(provider, signal); + }); + + describe("when the collection does not exist", () => { + it("throws an error", async () => { + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "foo", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain(`Collection '${integration.randomDbName()}.foo' does not exist`); + }); + }); + + describe("when the database does not exist", () => { + it("throws an error", async () => { + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: "nonexistent_db", + collection: "foo", + definition: [ + { + type: "vectorSearch", + fields: [{ type: "vector", path: "vector_1", numDimensions: 4 }], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain(`Collection 'nonexistent_db.foo' does not exist`); + }); + }); + + describe("when the collection exists", () => { + it("creates the index", async () => { + const collection = new ObjectId().toString(); + await provider.createCollection(integration.randomDbName(), collection); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection, + name: "vector_1_vector", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "vector_1_vector" on collection "${collection}" in database "${integration.randomDbName()}". Since this is a vector search index, it may take a while for the index to build. Use the \`list-indexes\` tool to check the index status.` + ); + + const indexes = await provider.getSearchIndexes(integration.randomDbName(), collection); + expect(indexes).toHaveLength(1); + expect(indexes[0]?.name).toEqual("vector_1_vector"); + expect(indexes[0]?.type).toEqual("vectorSearch"); + expect(indexes[0]?.status).toEqual("PENDING"); + expect(indexes[0]?.queryable).toEqual(false); + expect(indexes[0]?.latestDefinition).toEqual({ + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4, similarity: "euclidean" }, + { type: "filter", path: "category" }, + ], + }); + }); + }); + }, + { + getUserConfig: () => { + return { + ...defaultTestConfig, + voyageApiKey: "valid_key", + }; + }, + downloadOptions: { + search: true, + }, + } +); diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts new file mode 100644 index 000000000..46360b81b --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -0,0 +1,179 @@ +import { describe, beforeEach, it, afterEach, expect } from "vitest"; +import type { Collection } from "mongodb"; +import { + databaseCollectionInvalidArgs, + databaseCollectionParameters, + getDataFromUntrustedContent, + getResponseContent, + validateThrowsForInvalidArguments, + validateToolMetadata, +} from "../../../helpers.js"; +import { describeWithMongoDB } from "../mongodbHelpers.js"; +import { createMockElicitInput } from "../../../../utils/elicitationMocks.js"; +import { Elicitation } from "../../../../../src/elicitation.js"; + +describeWithMongoDB("drop-index tool", (integration) => { + let moviesCollection: Collection; + let indexName: string; + beforeEach(async () => { + await integration.connectMcpClient(); + const client = integration.mongoClient(); + moviesCollection = client.db("mflix").collection("movies"); + await moviesCollection.insertMany([ + { + name: "Movie1", + year: 1994, + }, + { + name: "Movie2", + year: 2001, + }, + ]); + indexName = await moviesCollection.createIndex({ year: 1 }); + }); + + afterEach(async () => { + await moviesCollection.drop(); + }); + + validateToolMetadata(integration, "drop-index", "Drop an index for the provided database and collection.", [ + ...databaseCollectionParameters, + { + name: "indexName", + type: "string", + description: "The name of the index to be dropped.", + required: true, + }, + ]); + + validateThrowsForInvalidArguments(integration, "drop-index", [ + ...databaseCollectionInvalidArgs, + { database: "test", collection: "testColl", indexName: null }, + { database: "test", collection: "testColl", indexName: undefined }, + { database: "test", collection: "testColl", indexName: [] }, + { database: "test", collection: "testColl", indexName: true }, + { database: "test", collection: "testColl", indexName: false }, + { database: "test", collection: "testColl", indexName: 0 }, + { database: "test", collection: "testColl", indexName: 12 }, + { database: "test", collection: "testColl", indexName: "" }, + ]); + + describe.each([ + { + database: "mflix", + collection: "non-existent", + }, + { + database: "non-db", + collection: "non-coll", + }, + ])( + "when attempting to delete an index from non-existent namespace - $database $collection", + ({ database, collection }) => { + it("should fail with error", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database, collection, indexName: "non-existent" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual(`Error running drop-index: ns not found ${database}.${collection}`); + }); + } + ); + + describe("when attempting to delete an index that does not exist", () => { + it("should fail with error", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName: "non-existent" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual(`Error running drop-index: index not found with name [non-existent]`); + }); + }); + + describe("when attempting to delete an index that exists", () => { + it("should succeed", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + // The index is created in beforeEach + arguments: { database: "mflix", collection: "movies", indexName: indexName }, + }); + expect(response.isError).toBe(undefined); + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped the index from the provided namespace.`); + const data = getDataFromUntrustedContent(content); + expect(JSON.parse(data)).toMatchObject({ indexName, namespace: "mflix.movies" }); + }); + }); +}); + +const mockElicitInput = createMockElicitInput(); + +describeWithMongoDB( + "drop-index tool - when invoked via an elicitation enabled client", + (integration) => { + let moviesCollection: Collection; + let indexName: string; + + beforeEach(async () => { + moviesCollection = integration.mongoClient().db("mflix").collection("movies"); + await moviesCollection.insertMany([ + { name: "Movie1", year: 1994 }, + { name: "Movie2", year: 2001 }, + ]); + indexName = await moviesCollection.createIndex({ year: 1 }); + await integration.mcpClient().callTool({ + name: "connect", + arguments: { + connectionString: integration.connectionString(), + }, + }); + }); + + afterEach(async () => { + await moviesCollection.drop(); + }); + + it("should ask for confirmation before proceeding with tool call", async () => { + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmYes(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName }, + }); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the `year_1` index from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(1); + }); + + it("should not drop the index if the confirmation was not provided", async () => { + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmNo(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName }, + }); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the `year_1` index from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + }); + }, + { + getMockElicitationInput: () => mockElicitInput, + } +); diff --git a/tests/integration/tools/mongodb/mongodbClusterProcess.ts b/tests/integration/tools/mongodb/mongodbClusterProcess.ts new file mode 100644 index 000000000..bd0da659f --- /dev/null +++ b/tests/integration/tools/mongodb/mongodbClusterProcess.ts @@ -0,0 +1,104 @@ +import fs from "fs/promises"; +import path from "path"; +import type { MongoClusterOptions } from "mongodb-runner"; +import { GenericContainer } from "testcontainers"; +import { MongoCluster } from "mongodb-runner"; +import { ShellWaitStrategy } from "testcontainers/build/wait-strategies/shell-wait-strategy.js"; + +export type MongoRunnerConfiguration = { + runner: true; + downloadOptions: MongoClusterOptions["downloadOptions"]; + serverArgs: string[]; +}; + +export type MongoSearchConfiguration = { search: true; image?: string }; +export type MongoClusterConfiguration = MongoRunnerConfiguration | MongoSearchConfiguration; + +const DOWNLOAD_RETRIES = 10; + +export class MongoDBClusterProcess { + static async spinUp(config: MongoClusterConfiguration): Promise { + if (MongoDBClusterProcess.isSearchOptions(config)) { + const runningContainer = await new GenericContainer(config.image ?? "mongodb/mongodb-atlas-local:8") + .withExposedPorts(27017) + .withCommand(["/usr/local/bin/runner", "server"]) + .withWaitStrategy(new ShellWaitStrategy(`mongosh --eval 'db.test.getSearchIndexes()'`)) + .start(); + + return new MongoDBClusterProcess( + () => runningContainer.stop(), + () => + `mongodb://${runningContainer.getHost()}:${runningContainer.getMappedPort(27017)}/?directConnection=true` + ); + } else if (MongoDBClusterProcess.isMongoRunnerOptions(config)) { + const { downloadOptions, serverArgs } = config; + + const tmpDir = path.join(__dirname, "..", "..", "..", "tmp"); + await fs.mkdir(tmpDir, { recursive: true }); + let dbsDir = path.join(tmpDir, "mongodb-runner", "dbs"); + for (let i = 0; i < DOWNLOAD_RETRIES; i++) { + try { + const mongoCluster = await MongoCluster.start({ + tmpDir: dbsDir, + logDir: path.join(tmpDir, "mongodb-runner", "logs"), + topology: "standalone", + version: downloadOptions?.version ?? "8.0.12", + downloadOptions, + args: serverArgs, + }); + + return new MongoDBClusterProcess( + () => mongoCluster.close(), + () => mongoCluster.connectionString + ); + } catch (err) { + if (i < 5) { + // Just wait a little bit and retry + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.error(`Failed to start cluster in ${dbsDir}, attempt ${i}: ${err}`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } else { + // If we still fail after 5 seconds, try another db dir + console.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Failed to start cluster in ${dbsDir}, attempt ${i}: ${err}. Retrying with a new db dir.` + ); + dbsDir = path.join(tmpDir, "mongodb-runner", `dbs${i - 5}`); + } + } + } + throw new Error(`Could not download cluster with configuration: ${JSON.stringify(config)}`); + } else { + throw new Error(`Unsupported configuration: ${JSON.stringify(config)}`); + } + } + + private constructor( + private readonly tearDownFunction: () => Promise, + private readonly connectionStringFunction: () => string + ) {} + + connectionString(): string { + return this.connectionStringFunction(); + } + + async close(): Promise { + await this.tearDownFunction(); + } + + static isConfigurationSupportedInCurrentEnv(config: MongoClusterConfiguration): boolean { + if (MongoDBClusterProcess.isSearchOptions(config) && process.env.GITHUB_ACTIONS === "true") { + return process.platform === "linux"; + } + + return true; + } + + private static isSearchOptions(opt: MongoClusterConfiguration): opt is MongoSearchConfiguration { + return (opt as MongoSearchConfiguration)?.search === true; + } + + private static isMongoRunnerOptions(opt: MongoClusterConfiguration): opt is MongoRunnerConfiguration { + return (opt as MongoRunnerConfiguration)?.runner === true; + } +} diff --git a/tests/integration/tools/mongodb/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index 60961df32..579598646 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -1,5 +1,3 @@ -import type { MongoClusterOptions } from "mongodb-runner"; -import { MongoCluster } from "mongodb-runner"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs/promises"; @@ -12,10 +10,15 @@ import { defaultTestConfig, defaultDriverOptions, getDataFromUntrustedContent, + sleep, } from "../../helpers.js"; import type { UserConfig, DriverOptions } from "../../../../src/common/config.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { EJSON } from "bson"; +import { MongoDBClusterProcess } from "./mongodbClusterProcess.js"; +import type { MongoClusterConfiguration } from "./mongodbClusterProcess.js"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; +import type { createMockElicitInput, MockClientCapabilities } from "../../../utils/elicitationMocks.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -49,6 +52,12 @@ const testDataPaths = [ }, ]; +const DEFAULT_MONGODB_PROCESS_OPTIONS: MongoClusterConfiguration = { + runner: true, + downloadOptions: { enterprise: false }, + serverArgs: [], +}; + interface MongoDBIntegrationTest { mongoClient: () => MongoClient; connectionString: () => string; @@ -58,23 +67,42 @@ interface MongoDBIntegrationTest { export type MongoDBIntegrationTestCase = IntegrationTest & MongoDBIntegrationTest & { connectMcpClient: () => Promise }; +export type MongoSearchConfiguration = { search: true; image?: string }; + +export type TestSuiteConfig = { + getUserConfig: (mdbIntegration: MongoDBIntegrationTest) => UserConfig; + getDriverOptions: (mdbIntegration: MongoDBIntegrationTest) => DriverOptions; + downloadOptions: MongoClusterConfiguration; + getMockElicitationInput?: () => ReturnType; + getClientCapabilities?: () => MockClientCapabilities; +}; + +const defaultTestSuiteConfig: TestSuiteConfig = { + getUserConfig: () => defaultTestConfig, + getDriverOptions: () => defaultDriverOptions, + downloadOptions: DEFAULT_MONGODB_PROCESS_OPTIONS, +}; + export function describeWithMongoDB( name: string, fn: (integration: MongoDBIntegrationTestCase) => void, - getUserConfig: (mdbIntegration: MongoDBIntegrationTest) => UserConfig = () => defaultTestConfig, - getDriverOptions: (mdbIntegration: MongoDBIntegrationTest) => DriverOptions = () => defaultDriverOptions, - downloadOptions: MongoClusterOptions["downloadOptions"] = { enterprise: false }, - serverArgs: string[] = [] + partialTestSuiteConfig?: Partial ): void { - describe(name, () => { - const mdbIntegration = setupMongoDBIntegrationTest(downloadOptions, serverArgs); + const { getUserConfig, getDriverOptions, downloadOptions, getMockElicitationInput, getClientCapabilities } = { + ...defaultTestSuiteConfig, + ...partialTestSuiteConfig, + }; + describe.skipIf(!MongoDBClusterProcess.isConfigurationSupportedInCurrentEnv(downloadOptions))(name, () => { + const mdbIntegration = setupMongoDBIntegrationTest(downloadOptions); + const mockElicitInput = getMockElicitationInput?.(); const integration = setupIntegrationTest( () => ({ ...getUserConfig(mdbIntegration), }), () => ({ ...getDriverOptions(mdbIntegration), - }) + }), + { elicitInput: mockElicitInput, getClientCapabilities } ); fn({ @@ -94,10 +122,9 @@ export function describeWithMongoDB( } export function setupMongoDBIntegrationTest( - downloadOptions: MongoClusterOptions["downloadOptions"], - serverArgs: string[] + configuration: MongoClusterConfiguration = DEFAULT_MONGODB_PROCESS_OPTIONS ): MongoDBIntegrationTest { - let mongoCluster: MongoCluster | undefined; + let mongoCluster: MongoDBClusterProcess | undefined; let mongoClient: MongoClient | undefined; let randomDbName: string; @@ -111,44 +138,7 @@ export function setupMongoDBIntegrationTest( }); beforeAll(async function () { - // Downloading Windows executables in CI takes a long time because - // they include debug symbols... - const tmpDir = path.join(__dirname, "..", "..", "..", "tmp"); - await fs.mkdir(tmpDir, { recursive: true }); - - // On Windows, we may have a situation where mongod.exe is not fully released by the OS - // before we attempt to run it again, so we add a retry. - let dbsDir = path.join(tmpDir, "mongodb-runner", "dbs"); - for (let i = 0; i < 10; i++) { - try { - mongoCluster = await MongoCluster.start({ - tmpDir: dbsDir, - logDir: path.join(tmpDir, "mongodb-runner", "logs"), - topology: "standalone", - version: downloadOptions?.version ?? "8.0.12", - downloadOptions, - args: serverArgs, - }); - - return; - } catch (err) { - if (i < 5) { - // Just wait a little bit and retry - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - console.error(`Failed to start cluster in ${dbsDir}, attempt ${i}: ${err}`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } else { - // If we still fail after 5 seconds, try another db dir - console.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Failed to start cluster in ${dbsDir}, attempt ${i}: ${err}. Retrying with a new db dir.` - ); - dbsDir = path.join(tmpDir, "mongodb-runner", `dbs${i - 5}`); - } - } - } - - throw new Error("Failed to start cluster after 10 attempts"); + mongoCluster = await MongoDBClusterProcess.spinUp(configuration); }, 120_000); afterAll(async function () { @@ -161,7 +151,7 @@ export function setupMongoDBIntegrationTest( throw new Error("beforeAll() hook not ran yet"); } - return mongoCluster.connectionString; + return mongoCluster.connectionString(); }; return { @@ -172,7 +162,6 @@ export function setupMongoDBIntegrationTest( return mongoClient; }, connectionString: getConnectionString, - randomDbName: () => randomDbName, }; } @@ -268,6 +257,11 @@ export function prepareTestData(integration: MongoDBIntegrationTest): { }; } +export function getSingleDocFromUntrustedContent(content: string): T { + const data = getDataFromUntrustedContent(content); + return EJSON.parse(data, { relaxed: true }) as T; +} + export function getDocsFromUntrustedContent(content: string): T[] { const data = getDataFromUntrustedContent(content); return EJSON.parse(data, { relaxed: true }) as T[]; @@ -286,3 +280,57 @@ export async function getServerVersion(integration: MongoDBIntegrationTestCase): const serverStatus = await client.db("admin").admin().serverStatus(); return serverStatus.version as string; } + +const SEARCH_RETRIES = 200; + +export async function waitUntilSearchIsReady( + provider: NodeDriverServiceProvider, + abortSignal: AbortSignal +): Promise { + let lastError: unknown = null; + + for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { + try { + await provider.insertOne("tmp", "test", { field1: "yay" }); + await provider.createSearchIndexes("tmp", "test", [{ definition: { mappings: { dynamic: true } } }]); + await provider.dropCollection("tmp", "test"); + return; + } catch (err) { + lastError = err; + await sleep(100); + } + } + + throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); +} + +export async function waitUntilSearchIndexIsQueryable( + provider: NodeDriverServiceProvider, + database: string, + collection: string, + indexName: string, + abortSignal: AbortSignal +): Promise { + let lastIndexStatus: unknown = null; + let lastError: unknown = null; + + for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { + try { + const [indexStatus] = await provider.getSearchIndexes(database, collection, indexName); + lastIndexStatus = indexStatus; + + if (indexStatus?.queryable === true) { + return; + } + } catch (err) { + lastError = err; + await sleep(100); + } + } + + throw new Error( + `Index ${indexName} in ${database}.${collection} is not ready: +lastIndexStatus: ${JSON.stringify(lastIndexStatus)} +lastError: ${JSON.stringify(lastError)}` + ); +} diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index f2e4930a2..ea43345cd 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -1,9 +1,9 @@ -import { vi, it, describe, beforeEach, afterEach, type MockedFunction, afterAll, expect } from "vitest"; +import { vi, it, describe, beforeEach, afterEach, afterAll, expect } from "vitest"; import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { MongoDBToolBase } from "../../../../src/tools/mongodb/mongodbTool.js"; -import { type OperationType } from "../../../../src/tools/tool.js"; +import { type ToolBase, type ToolConstructorParams, type OperationType } from "../../../../src/tools/tool.js"; import { defaultDriverOptions, type UserConfig } from "../../../../src/common/config.js"; import { MCPConnectionManager } from "../../../../src/common/connectionManager.js"; import { Session } from "../../../../src/common/session.js"; @@ -19,6 +19,7 @@ import { setupMongoDBIntegrationTest } from "./mongodbHelpers.js"; import { ErrorCodes } from "../../../../src/common/errors.js"; import { Keychain } from "../../../../src/common/keychain.js"; import { Elicitation } from "../../../../src/elicitation.js"; +import { MongoDbTools } from "../../../../src/tools/mongodb/tools.js"; const injectedErrorHandler: ConnectionErrorHandler = (error) => { switch (error.code) { @@ -51,29 +52,45 @@ const injectedErrorHandler: ConnectionErrorHandler = (error) => { } }; -describe("MongoDBTool implementations", () => { - const mdbIntegration = setupMongoDBIntegrationTest({ enterprise: false }, []); - const executeStub: MockedFunction<() => Promise> = vi - .fn() - .mockResolvedValue({ content: [{ type: "text", text: "Something" }] }); - class RandomTool extends MongoDBToolBase { - name = "Random"; - operationType: OperationType = "read"; - protected description = "This is a tool."; - protected argsShape = {}; - public async execute(): Promise { - await this.ensureConnected(); - return executeStub(); +class RandomTool extends MongoDBToolBase { + name = "Random"; + operationType: OperationType = "read"; + protected description = "This is a tool."; + protected argsShape = {}; + public async execute(): Promise { + await this.ensureConnected(); + return { content: [{ type: "text", text: "Something" }] }; + } +} + +class UnusableVoyageTool extends MongoDBToolBase { + name = "UnusableVoyageTool"; + operationType: OperationType = "read"; + protected description = "This is a Voyage tool."; + protected argsShape = {}; + + override verifyAllowed(): boolean { + if (this.config.voyageApiKey.trim()) { + return super.verifyAllowed(); } + return false; + } + public async execute(): Promise { + await this.ensureConnected(); + return { content: [{ type: "text", text: "Something" }] }; } +} + +describe("MongoDBTool implementations", () => { + const mdbIntegration = setupMongoDBIntegrationTest(); - let tool: RandomTool | undefined; let mcpClient: Client | undefined; let mcpServer: Server | undefined; let deviceId: DeviceId | undefined; async function cleanupAndStartServer( config: Partial | undefined = {}, + toolConstructors: (new (params: ToolConstructorParams) => ToolBase)[] = [...MongoDbTools, RandomTool], errorHandler: ConnectionErrorHandler | undefined = connectionErrorHandler ): Promise { await cleanup(); @@ -126,16 +143,9 @@ describe("MongoDBTool implementations", () => { mcpServer: internalMcpServer, connectionErrorHandler: errorHandler, elicitation, + toolConstructors, }); - tool = new RandomTool({ - session, - config: userConfig, - telemetry, - elicitation, - }); - tool.register(mcpServer); - await mcpServer.connect(serverTransport); await mcpClient.connect(clientTransport); } @@ -150,8 +160,6 @@ describe("MongoDBTool implementations", () => { deviceId?.close(); deviceId = undefined; - - tool = undefined; } beforeEach(async () => { @@ -232,7 +240,7 @@ describe("MongoDBTool implementations", () => { describe("when MCP is using injected connection error handler", () => { beforeEach(async () => { - await cleanupAndStartServer(defaultTestConfig, injectedErrorHandler); + await cleanupAndStartServer(defaultTestConfig, [...MongoDbTools, RandomTool], injectedErrorHandler); }); describe("and comes across a MongoDB Error - NotConnectedToMongoDB", () => { @@ -256,7 +264,11 @@ describe("MongoDBTool implementations", () => { describe("and comes across a MongoDB Error - MisconfiguredConnectionString", () => { it("should handle the error", async () => { // This is a misconfigured connection string - await cleanupAndStartServer({ connectionString: "mongodb://localhost:1234" }, injectedErrorHandler); + await cleanupAndStartServer( + { connectionString: "mongodb://localhost:1234" }, + [...MongoDbTools, RandomTool], + injectedErrorHandler + ); const toolResponse = await mcpClient?.callTool({ name: "Random", arguments: {}, @@ -278,6 +290,7 @@ describe("MongoDBTool implementations", () => { // This is a misconfigured connection string await cleanupAndStartServer( { connectionString: mdbIntegration.connectionString(), indexCheck: true }, + [...MongoDbTools, RandomTool], injectedErrorHandler ); const toolResponse = await mcpClient?.callTool({ @@ -299,4 +312,17 @@ describe("MongoDBTool implementations", () => { }); }); }); + + describe("when a tool is not usable", () => { + it("should not even be registered", async () => { + await cleanupAndStartServer( + { connectionString: mdbIntegration.connectionString(), indexCheck: true }, + [RandomTool, UnusableVoyageTool], + injectedErrorHandler + ); + const tools = await mcpClient?.listTools({}); + expect(tools?.tools).toHaveLength(1); + expect(tools?.tools.find((tool) => tool.name === "UnusableVoyageTool")).toBeUndefined(); + }); + }); }); diff --git a/tests/integration/tools/mongodb/read/aggregate.test.ts b/tests/integration/tools/mongodb/read/aggregate.test.ts index 3f0a99a58..d585d5786 100644 --- a/tests/integration/tools/mongodb/read/aggregate.test.ts +++ b/tests/integration/tools/mongodb/read/aggregate.test.ts @@ -282,7 +282,9 @@ describeWithMongoDB( ); }); }, - () => ({ ...defaultTestConfig, maxDocumentsPerQuery: 20 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxDocumentsPerQuery: 20 }), + } ); describeWithMongoDB( @@ -339,7 +341,9 @@ describeWithMongoDB( ); }); }, - () => ({ ...defaultTestConfig, maxBytesPerQuery: 200 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxBytesPerQuery: 200 }), + } ); describeWithMongoDB( @@ -369,5 +373,7 @@ describeWithMongoDB( expect(content).toContain(`Returning 990 documents.`); }); }, - () => ({ ...defaultTestConfig, maxDocumentsPerQuery: -1, maxBytesPerQuery: -1 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxDocumentsPerQuery: -1, maxBytesPerQuery: -1 }), + } ); diff --git a/tests/integration/tools/mongodb/read/export.test.ts b/tests/integration/tools/mongodb/read/export.test.ts index b20ca7229..2c0310b6e 100644 --- a/tests/integration/tools/mongodb/read/export.test.ts +++ b/tests/integration/tools/mongodb/read/export.test.ts @@ -458,5 +458,7 @@ describeWithMongoDB( }); }); }, - () => userConfig + { + getUserConfig: () => userConfig, + } ); diff --git a/tests/integration/tools/mongodb/read/find.test.ts b/tests/integration/tools/mongodb/read/find.test.ts index 3619e423c..c466650fa 100644 --- a/tests/integration/tools/mongodb/read/find.test.ts +++ b/tests/integration/tools/mongodb/read/find.test.ts @@ -341,7 +341,9 @@ describeWithMongoDB( ); }); }, - () => ({ ...defaultTestConfig, maxDocumentsPerQuery: 10 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxDocumentsPerQuery: 10 }), + } ); describeWithMongoDB( @@ -391,7 +393,9 @@ describeWithMongoDB( ); }); }, - () => ({ ...defaultTestConfig, maxBytesPerQuery: 100 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxBytesPerQuery: 100 }), + } ); describeWithMongoDB( @@ -441,5 +445,7 @@ describeWithMongoDB( ); }); }, - () => ({ ...defaultTestConfig, maxDocumentsPerQuery: -1, maxBytesPerQuery: -1 }) + { + getUserConfig: () => ({ ...defaultTestConfig, maxDocumentsPerQuery: -1, maxBytesPerQuery: -1 }), + } ); diff --git a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts new file mode 100644 index 000000000..477f9faee --- /dev/null +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -0,0 +1,126 @@ +import { + describeWithMongoDB, + getSingleDocFromUntrustedContent, + waitUntilSearchIndexIsQueryable, + waitUntilSearchIsReady, +} from "../mongodbHelpers.js"; +import { describe, it, expect, beforeEach } from "vitest"; +import { + getResponseContent, + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + databaseCollectionInvalidArgs, + getDataFromUntrustedContent, +} from "../../../helpers.js"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; +import type { SearchIndexStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; + +const SEARCH_TIMEOUT = 20_000; + +describeWithMongoDB("list search indexes tool in local MongoDB", (integration) => { + validateToolMetadata( + integration, + "list-search-indexes", + "Describes the search and vector search indexes for a single collection", + databaseCollectionParameters + ); + + validateThrowsForInvalidArguments(integration, "list-search-indexes", databaseCollectionInvalidArgs); + + it("fails for clusters without MongoDB Search", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "list-search-indexes", + arguments: { database: "any", collection: "foo" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + "This MongoDB cluster does not support Search Indexes. Make sure you are using an Atlas Cluster, either remotely in Atlas or using the Atlas Local image, or your cluster supports MongoDB Search." + ); + }); +}); + +describeWithMongoDB( + "list search indexes tool in Atlas", + (integration) => { + let provider: NodeDriverServiceProvider; + + beforeEach(async ({ signal }) => { + await integration.connectMcpClient(); + provider = integration.mcpServer().session.serviceProvider; + await waitUntilSearchIsReady(provider, signal); + }); + + describe("when the collection does not exist", () => { + it("returns an empty list of indexes", async () => { + const response = await integration.mcpClient().callTool({ + name: "list-search-indexes", + arguments: { database: "any", collection: "foo" }, + }); + const responseContent = getResponseContent(response.content); + const content = getDataFromUntrustedContent(responseContent); + expect(responseContent).toContain("Could not retrieve search indexes"); + expect(content).toEqual("There are no search or vector search indexes in any.foo"); + }); + }); + + describe("when there are no indexes", () => { + it("returns an empty list of indexes", async () => { + const response = await integration.mcpClient().callTool({ + name: "list-search-indexes", + arguments: { database: "any", collection: "foo" }, + }); + const responseContent = getResponseContent(response.content); + const content = getDataFromUntrustedContent(responseContent); + expect(responseContent).toContain("Could not retrieve search indexes"); + expect(content).toEqual("There are no search or vector search indexes in any.foo"); + }); + }); + + describe("when there are indexes", () => { + beforeEach(async () => { + await provider.insertOne("any", "foo", { field1: "yay" }); + await provider.createSearchIndexes("any", "foo", [{ definition: { mappings: { dynamic: true } } }]); + }); + + it("returns the list of existing indexes", { timeout: SEARCH_TIMEOUT }, async () => { + const response = await integration.mcpClient().callTool({ + name: "list-search-indexes", + arguments: { database: "any", collection: "foo" }, + }); + const content = getResponseContent(response.content); + const indexDefinition = getSingleDocFromUntrustedContent(content); + + expect(indexDefinition?.name).toEqual("default"); + expect(indexDefinition?.type).toEqual("search"); + expect(indexDefinition?.latestDefinition).toEqual({ mappings: { dynamic: true, fields: {} } }); + }); + + it( + "returns the list of existing indexes and detects if they are queryable", + { timeout: SEARCH_TIMEOUT }, + async ({ signal }) => { + await waitUntilSearchIndexIsQueryable(provider, "any", "foo", "default", signal); + + const response = await integration.mcpClient().callTool({ + name: "list-search-indexes", + arguments: { database: "any", collection: "foo" }, + }); + + const content = getResponseContent(response.content); + const indexDefinition = getSingleDocFromUntrustedContent(content); + + expect(indexDefinition?.name).toEqual("default"); + expect(indexDefinition?.type).toEqual("search"); + expect(indexDefinition?.latestDefinition).toEqual({ mappings: { dynamic: true, fields: {} } }); + expect(indexDefinition?.queryable).toEqual(true); + expect(indexDefinition?.status).toEqual("READY"); + } + ); + }); + }, + { + downloadOptions: { search: true }, + } +); diff --git a/tests/integration/transports/stdio.test.ts b/tests/integration/transports/stdio.test.ts index aaa61d638..b5ed80840 100644 --- a/tests/integration/transports/stdio.test.ts +++ b/tests/integration/transports/stdio.test.ts @@ -32,7 +32,7 @@ describeWithMongoDB("StdioRunner", (integration) => { const response = await client.listTools(); expect(response).toBeDefined(); expect(response.tools).toBeDefined(); - expect(response.tools).toHaveLength(21); + expect(response.tools).toHaveLength(22); const sortedTools = response.tools.sort((a, b) => a.name.localeCompare(b.name)); expect(sortedTools[0]?.name).toBe("aggregate"); diff --git a/tests/unit/common/session.test.ts b/tests/unit/common/session.test.ts index 9402df246..7b3176113 100644 --- a/tests/unit/common/session.test.ts +++ b/tests/unit/common/session.test.ts @@ -1,4 +1,4 @@ -import type { Mocked } from "vitest"; +import type { Mocked, MockedFunction } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { Session } from "../../../src/common/session.js"; @@ -119,4 +119,30 @@ describe("Session", () => { expect(connectionString).toContain("--test-device-id--unknown"); }); }); + + describe("isSearchIndexSupported", () => { + let getSearchIndexesMock: MockedFunction<() => unknown>; + beforeEach(() => { + getSearchIndexesMock = vi.fn(); + MockNodeDriverServiceProvider.connect = vi.fn().mockResolvedValue({ + getSearchIndexes: getSearchIndexesMock, + } as unknown as NodeDriverServiceProvider); + }); + + it("should return true if listing search indexes succeed", async () => { + getSearchIndexesMock.mockResolvedValue([]); + await session.connectToMongoDB({ + connectionString: "mongodb://localhost:27017", + }); + expect(await session.isSearchSupported()).toEqual(true); + }); + + it("should return false if listing search indexes fail with search error", async () => { + getSearchIndexesMock.mockRejectedValue(new Error("SearchNotEnabled")); + await session.connectToMongoDB({ + connectionString: "mongodb://localhost:27017", + }); + expect(await session.isSearchSupported()).toEqual(false); + }); + }); }); diff --git a/tests/unit/resources/common/debug.test.ts b/tests/unit/resources/common/debug.test.ts index f031fd218..56b1409d9 100644 --- a/tests/unit/resources/common/debug.test.ts +++ b/tests/unit/resources/common/debug.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DebugResource } from "../../../../src/resources/common/debug.js"; import { Session } from "../../../../src/common/session.js"; import { Telemetry } from "../../../../src/telemetry/telemetry.js"; @@ -13,13 +13,15 @@ import { Keychain } from "../../../../src/common/keychain.js"; describe("debug resource", () => { const logger = new CompositeLogger(); const deviceId = DeviceId.create(logger); - const session = new Session({ - apiBaseUrl: "", - logger, - exportsManager: ExportsManager.init(config, logger), - connectionManager: new MCPConnectionManager(config, driverOptions, logger, deviceId), - keychain: new Keychain(), - }); + const session = vi.mocked( + new Session({ + apiBaseUrl: "", + logger, + exportsManager: ExportsManager.init(config, logger), + connectionManager: new MCPConnectionManager(config, driverOptions, logger, deviceId), + keychain: new Keychain(), + }) + ); const telemetry = Telemetry.create(session, { ...config, telemetry: "disabled" }, deviceId); let debugResource: DebugResource = new DebugResource(session, config, telemetry); @@ -28,54 +30,56 @@ describe("debug resource", () => { debugResource = new DebugResource(session, config, telemetry); }); - it("should be connected when a connected event happens", () => { + it("should be connected when a connected event happens", async () => { debugResource.reduceApply("connect", undefined); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); - expect(output).toContain(`The user is connected to the MongoDB cluster.`); + expect(output).toContain( + `The user is connected to the MongoDB cluster without any support for search indexes.` + ); }); - it("should be disconnected when a disconnect event happens", () => { + it("should be disconnected when a disconnect event happens", async () => { debugResource.reduceApply("disconnect", undefined); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); expect(output).toContain(`The user is not connected to a MongoDB cluster.`); }); - it("should be disconnected when a close event happens", () => { + it("should be disconnected when a close event happens", async () => { debugResource.reduceApply("close", undefined); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); expect(output).toContain(`The user is not connected to a MongoDB cluster.`); }); - it("should be disconnected and contain an error when an error event occurred", () => { + it("should be disconnected and contain an error when an error event occurred", async () => { debugResource.reduceApply("connection-error", { tag: "errored", errorReason: "Error message from the server", }); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); expect(output).toContain(`The user is not connected to a MongoDB cluster because of an error.`); expect(output).toContain(`Error message from the server`); }); - it("should show the inferred authentication type", () => { + it("should show the inferred authentication type", async () => { debugResource.reduceApply("connection-error", { tag: "errored", connectionStringAuthType: "scram", errorReason: "Error message from the server", }); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); expect(output).toContain(`The user is not connected to a MongoDB cluster because of an error.`); expect(output).toContain(`The inferred authentication mechanism is "scram".`); expect(output).toContain(`Error message from the server`); }); - it("should show the atlas cluster information when provided", () => { + it("should show the atlas cluster information when provided", async () => { debugResource.reduceApply("connection-error", { tag: "errored", connectionStringAuthType: "scram", @@ -88,7 +92,7 @@ describe("debug resource", () => { }, }); - const output = debugResource.toOutput(); + const output = await debugResource.toOutput(); expect(output).toContain(`The user is not connected to a MongoDB cluster because of an error.`); expect(output).toContain( @@ -97,4 +101,12 @@ describe("debug resource", () => { expect(output).toContain(`The inferred authentication mechanism is "scram".`); expect(output).toContain(`Error message from the server`); }); + + it("should notify if a cluster supports search indexes", async () => { + vi.spyOn(session, "isSearchSupported").mockImplementation(() => Promise.resolve(true)); + debugResource.reduceApply("connect", undefined); + const output = await debugResource.toOutput(); + + expect(output).toContain(`The user is connected to the MongoDB cluster with support for search indexes.`); + }); }); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index 0dd759314..8b4a0c9c1 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -9,6 +9,7 @@ import { NullLogger } from "../../tests/utils/index.js"; import type { MockedFunction } from "vitest"; import type { DeviceId } from "../../src/helpers/deviceId.js"; import { expectDefined } from "../integration/helpers.js"; +import { Keychain } from "../../src/common/keychain.js"; // Mock the ApiClient to avoid real API calls vi.mock("../../src/common/atlas/apiClient.js"); @@ -140,6 +141,7 @@ describe("Telemetry", () => { close: vi.fn().mockResolvedValue(undefined), setAgentRunner: vi.fn().mockResolvedValue(undefined), logger: new NullLogger(), + keychain: new Keychain(), } as unknown as Session; telemetry = Telemetry.create(session, config, mockDeviceId, { @@ -345,4 +347,91 @@ describe("Telemetry", () => { verifyMockCalls(); }); }); + + describe("when secrets are registered", () => { + describe("comprehensive redaction coverage", () => { + it("should redact sensitive data from CommonStaticProperties", async () => { + session.keychain.register("secret-server-version", "password"); + session.keychain.register("secret-server-name", "password"); + session.keychain.register("secret-password", "password"); + session.keychain.register("secret-key", "password"); + session.keychain.register("secret-token", "password"); + session.keychain.register("secret-password-version", "password"); + + // Simulates sensitive data across random properties + const sensitiveStaticProps = { + mcp_server_version: "secret-server-version", + mcp_server_name: "secret-server-name", + platform: "linux-secret-password", + arch: "x64-secret-key", + os_type: "linux-secret-token", + os_version: "secret-password-version", + }; + + telemetry = Telemetry.create(session, config, mockDeviceId, { + eventCache: mockEventCache as unknown as EventCache, + commonProperties: sensitiveStaticProps, + }); + + await telemetry.setupPromise; + + telemetry.emitEvents([createTestEvent()]); + + const calls = mockApiClient.sendEvents.mock.calls; + expect(calls).toHaveLength(1); + + // get event properties + const sentEvent = calls[0]?.[0][0] as { properties: Record }; + expectDefined(sentEvent); + + const eventProps = sentEvent.properties; + expect(eventProps.mcp_server_version).toBe(""); + expect(eventProps.mcp_server_name).toBe(""); + expect(eventProps.platform).toBe("linux-"); + expect(eventProps.arch).toBe("x64-"); + expect(eventProps.os_type).toBe("linux-"); + expect(eventProps.os_version).toBe("-version"); + }); + + it("should redact sensitive data from CommonProperties", () => { + // register the common properties as sensitive data + session.keychain.register("test-device-id", "password"); + session.keychain.register(session.sessionId, "password"); + + telemetry.emitEvents([createTestEvent()]); + + const calls = mockApiClient.sendEvents.mock.calls; + expect(calls).toHaveLength(1); + + // get event properties + const sentEvent = calls[0]?.[0][0] as { properties: Record }; + expectDefined(sentEvent); + + const eventProps = sentEvent.properties; + + expect(eventProps.device_id).toBe(""); + expect(eventProps.session_id).toBe(""); + }); + + it("should redact sensitive data that is added to events", () => { + session.keychain.register("test-device-id", "password"); + session.keychain.register(session.sessionId, "password"); + session.keychain.register("test-component", "password"); + + telemetry.emitEvents([createTestEvent()]); + + const calls = mockApiClient.sendEvents.mock.calls; + expect(calls).toHaveLength(1); + + // get event properties + const sentEvent = calls[0]?.[0][0] as { properties: Record }; + expectDefined(sentEvent); + + const eventProps = sentEvent.properties; + expect(eventProps.device_id).toBe(""); + expect(eventProps.session_id).toBe(""); + expect(eventProps.component).toBe(""); + }); + }); + }); });