From 2aa8f850572740a305386b27390e7c9525475940 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 7 Oct 2025 12:28:51 +0200 Subject: [PATCH 01/28] chore: downgrade yargs-parser to v21 (#618) --- .github/dependabot.yml | 16 ++++++++++++++++ package-lock.json | 30 +++++------------------------- package.json | 4 ++-- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 782a0ad75..4c86fb00b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,22 @@ 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: diff --git a/package-lock.json b/package-lock.json index c52a96fc9..a50a5af4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { @@ -11279,16 +11279,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", @@ -15401,12 +15391,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 +15444,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", diff --git a/package.json b/package.json index 72981fc6f..d7dfbe76c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -93,7 +94,6 @@ "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 +115,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": { From f384834ee558a0a9c79c0105194b0f6dcdc12fe4 Mon Sep 17 00:00:00 2001 From: "mongodb-devtools-bot[bot]" <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:54:28 +0200 Subject: [PATCH 02/28] chore: release v1.0.3-prerelease.1 (#619) Co-authored-by: mongodb-devtools-bot[bot] <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- src/common/packageInfo.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a50a5af4b..08e04b9ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-mcp-server", - "version": "1.0.2", + "version": "1.0.3-prerelease.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-mcp-server", - "version": "1.0.2", + "version": "1.0.3-prerelease.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", diff --git a/package.json b/package.json index d7dfbe76c..89a590c09 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.0.3-prerelease.1", "type": "module", "exports": { ".": { diff --git a/src/common/packageInfo.ts b/src/common/packageInfo.ts index 813189e22..b65b24839 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.0.3-prerelease.1", mcpServerName: "MongoDB MCP Server", }; From 2572bc3cde75aeddf710dd81b2cac809cf286e55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:38:14 +0200 Subject: [PATCH 03/28] chore(deps): bump @modelcontextprotocol/sdk from 1.18.0 to 1.19.1 (#614) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 08e04b9ed..67d15d63e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2144,9 +2144,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", From 6c5168d35796149daef09dbb06338e87257f58ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:40:00 +0200 Subject: [PATCH 04/28] chore(deps): bump mongodb-redact from 1.2.0 to 1.2.1 (#613) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67d15d63e..f03b02d86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10690,9 +10690,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" From 6d7a8ad2844449063befac375441e410d1b25a96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:41:49 +0100 Subject: [PATCH 05/28] chore(deps): bump peter-evans/create-or-update-comment from 4.0.0 to 5.0.0 (#616) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/jira-issue.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: | From 543301c2d500895fd76f4b5caf1808d453f7170f Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 8 Oct 2025 12:24:25 +0200 Subject: [PATCH 06/28] chore: allow providing Voyage AI API key from config and tests to ensure unusable tools are not registered MCP-233 MCP-237 (#622) --- README.md | 1 + src/common/config.ts | 3 + src/server.ts | 17 +++- .../tools/mongodb/mongodbTool.test.ts | 82 ++++++++++++------- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f8ea9afd6..469e33754 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 diff --git a/src/common/config.ts b/src/common/config.ts index cbac900c4..efcc7b4a6 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,7 @@ export interface UserConfig extends CliOptions { maxDocumentsPerQuery: number; maxBytesPerQuery: number; atlasTemporaryDatabaseUserLifetimeMs: number; + voyageApiKey: string; } export const defaultUserConfig: UserConfig = { @@ -210,6 +212,7 @@ 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: "", }; export const config = setupUserConfig({ diff --git a/src/server.ts b/src/server.ts index 458bcd28b..794fb9867 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 { @@ -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/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index f2e4930a2..32ec0f6d1 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({ enterprise: false }, []); - 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(); + }); + }); }); From 46bf661d5bb77e5244b46a6a10ea9f22592a0567 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 8 Oct 2025 13:17:37 +0200 Subject: [PATCH 07/28] chore: add isError annotation for handled errors (#623) --- src/tools/mongodb/metadata/collectionStorageSize.ts | 1 + src/tools/mongodb/read/collectionIndexes.ts | 1 + src/tools/mongodb/update/renameCollection.ts | 2 ++ 3 files changed, 4 insertions(+) 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/read/collectionIndexes.ts b/src/tools/mongodb/read/collectionIndexes.ts index 818561917..84b8b1dbb 100644 --- a/src/tools/mongodb/read/collectionIndexes.ts +++ b/src/tools/mongodb/read/collectionIndexes.ts @@ -37,6 +37,7 @@ export class CollectionIndexesTool extends MongoDBToolBase { type: "text", }, ], + isError: true, }; } 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, }; } } From c12de89b1edd7f9123b100d78e985aa7d6c591d9 Mon Sep 17 00:00:00 2001 From: Kevin Mas Ruiz Date: Wed, 8 Oct 2025 15:20:30 +0200 Subject: [PATCH 08/28] feat(search): Add a new tool to list search and vector search indexes MCP-235 (#610) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/code-health-fork.yml | 5 + .github/workflows/code-health.yml | 5 + package-lock.json | 927 +++++++++++++++++- package.json | 1 + src/tools/mongodb/search/listSearchIndexes.ts | 81 ++ src/tools/mongodb/tools.ts | 2 + tests/accuracy/listSearchIndexes.test.ts | 28 + tests/accuracy/sdk/describeAccuracyTests.ts | 2 +- .../common/connectionManager.oidc.test.ts | 3 +- tests/integration/helpers.ts | 4 + .../integration/tools/atlas/clusters.test.ts | 6 +- .../tools/mongodb/mongodbClusterProcess.ts | 103 ++ .../tools/mongodb/mongodbHelpers.ts | 71 +- .../tools/mongodb/mongodbTool.test.ts | 2 +- .../mongodb/search/listSearchIndexes.test.ts | 171 ++++ 15 files changed, 1322 insertions(+), 89 deletions(-) create mode 100644 src/tools/mongodb/search/listSearchIndexes.ts create mode 100644 tests/accuracy/listSearchIndexes.test.ts create mode 100644 tests/integration/tools/mongodb/mongodbClusterProcess.ts create mode 100644 tests/integration/tools/mongodb/search/listSearchIndexes.test.ts 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/package-lock.json b/package-lock.json index f03b02d86..7a6a7d015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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", @@ -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", @@ -6117,6 +6248,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 +6530,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 +6546,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 +6588,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 +6955,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 +7341,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 +7505,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 +7643,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" @@ -7497,26 +7973,103 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, - "license": "BSD-3-Clause", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "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": ">=0.3.1" + "node": ">= 6" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "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" + "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/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "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", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/dompurify": { @@ -8152,6 +8705,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 +8826,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", @@ -8735,6 +9305,19 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -10088,6 +10671,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 +10764,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 +11105,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", @@ -11906,6 +12556,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", @@ -11948,8 +12615,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" @@ -12224,6 +12891,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", @@ -13330,12 +14030,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", @@ -13408,6 +14137,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", @@ -13837,11 +14578,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", @@ -13853,8 +14594,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", @@ -13865,6 +14606,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", @@ -13880,7 +14622,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -13890,15 +14631,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", @@ -13912,8 +14653,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", @@ -14096,6 +14837,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", @@ -14208,6 +15050,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", @@ -15355,8 +16207,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15478,6 +16328,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 89a590c09..2801da8de 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,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", 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..1567fd4f8 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -19,6 +19,7 @@ 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"; export const MongoDbTools = [ ConnectTool, @@ -42,4 +43,5 @@ export const MongoDbTools = [ CreateCollectionTool, LogsTool, ExportTool, + ListSearchIndexesTool, ]; 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/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index 6617a84f7..8073518b4 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -63,7 +63,7 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) eachModel(`$displayName`, function (model) { const configsWithDescriptions = getConfigsWithDescriptions(accuracyTestConfigs); const accuracyRunId = `${process.env.MDB_ACCURACY_RUN_ID}`; - const mdbIntegration = setupMongoDBIntegrationTest({}, []); + const mdbIntegration = setupMongoDBIntegrationTest(); const { populateTestData, cleanupTestDatabases } = prepareTestData(mdbIntegration); let commitSHA: string; diff --git a/tests/integration/common/connectionManager.oidc.test.ts b/tests/integration/common/connectionManager.oidc.test.ts index de1801a56..9f30cf32e 100644 --- a/tests/integration/common/connectionManager.oidc.test.ts +++ b/tests/integration/common/connectionManager.oidc.test.ts @@ -144,8 +144,7 @@ describe.skipIf(process.platform !== "linux")("ConnectionManager OIDC Tests", as defaults: {}, }), }), - { enterprise: true, version: mongodbVersion }, - serverArgs + { runner: true, downloadOptions: { enterprise: true, version: mongodbVersion }, serverArgs } ); } diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index d62354a83..6282851cf 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -382,3 +382,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/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 0e666be7d..0c7b75596 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,13 +1,9 @@ import type { Session } from "../../../../src/common/session.js"; -import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; +import { expectDefined, getDataFromUntrustedContent, getResponseElements, sleep } from "../../helpers.js"; import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js"; import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.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, diff --git a/tests/integration/tools/mongodb/mongodbClusterProcess.ts b/tests/integration/tools/mongodb/mongodbClusterProcess.ts new file mode 100644 index 000000000..b0f7ee863 --- /dev/null +++ b/tests/integration/tools/mongodb/mongodbClusterProcess.ts @@ -0,0 +1,103 @@ +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)}` + ); + } 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..e3a332ae8 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"; @@ -16,6 +14,8 @@ import { 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"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -49,6 +49,12 @@ const testDataPaths = [ }, ]; +const DEFAULT_MONGODB_PROCESS_OPTIONS: MongoClusterConfiguration = { + runner: true, + downloadOptions: { enterprise: false }, + serverArgs: [], +}; + interface MongoDBIntegrationTest { mongoClient: () => MongoClient; connectionString: () => string; @@ -58,16 +64,17 @@ interface MongoDBIntegrationTest { export type MongoDBIntegrationTestCase = IntegrationTest & MongoDBIntegrationTest & { connectMcpClient: () => Promise }; +export type MongoSearchConfiguration = { search: true; image?: string }; + 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[] = [] + downloadOptions: MongoClusterConfiguration = DEFAULT_MONGODB_PROCESS_OPTIONS ): void { - describe(name, () => { - const mdbIntegration = setupMongoDBIntegrationTest(downloadOptions, serverArgs); + describe.skipIf(!MongoDBClusterProcess.isConfigurationSupportedInCurrentEnv(downloadOptions))(name, () => { + const mdbIntegration = setupMongoDBIntegrationTest(downloadOptions); const integration = setupIntegrationTest( () => ({ ...getUserConfig(mdbIntegration), @@ -94,10 +101,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 +117,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 +130,7 @@ export function setupMongoDBIntegrationTest( throw new Error("beforeAll() hook not ran yet"); } - return mongoCluster.connectionString; + return mongoCluster.connectionString(); }; return { @@ -172,7 +141,6 @@ export function setupMongoDBIntegrationTest( return mongoClient; }, connectionString: getConnectionString, - randomDbName: () => randomDbName, }; } @@ -268,6 +236,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[]; diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index 32ec0f6d1..ea43345cd 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -82,7 +82,7 @@ class UnusableVoyageTool extends MongoDBToolBase { } describe("MongoDBTool implementations", () => { - const mdbIntegration = setupMongoDBIntegrationTest({ enterprise: false }, []); + const mdbIntegration = setupMongoDBIntegrationTest(); let mcpClient: Client | undefined; let mcpServer: Server | undefined; 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..97571c0a9 --- /dev/null +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -0,0 +1,171 @@ +import { describeWithMongoDB, getSingleDocFromUntrustedContent } from "../mongodbHelpers.js"; +import { describe, it, expect, beforeEach } from "vitest"; +import { + getResponseContent, + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + databaseCollectionInvalidArgs, + sleep, + 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_RETRIES = 200; +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 waitUntilIndexIsQueryable(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"); + } + ); + }); + }, + undefined, // default user config + undefined, // default driver config + { search: true } // use a search cluster +); + +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 } } }]); + return; + } catch (err) { + lastError = err; + await sleep(100); + } + } + + throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); +} + +async function waitUntilIndexIsQueryable( + 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)}` + ); +} From d2fa04c3bd306e0b817d81f4f70bc57ec7e791aa Mon Sep 17 00:00:00 2001 From: Kyle Lai <122811196+kylelai1@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:52:28 -0400 Subject: [PATCH 09/28] feat: Atlas list performance advisor tool (#609) --- scripts/accuracy/runAccuracyTests.sh | 5 + scripts/filter.ts | 4 + src/common/atlas/apiClient.ts | 48 ++ src/common/atlas/cluster.ts | 34 +- src/common/atlas/openapi.d.ts | 499 ++++++++++++++++++ src/common/atlas/performanceAdvisorUtils.ts | 153 ++++++ src/common/logger.ts | 5 + src/tools/atlas/atlasTool.ts | 17 +- src/tools/atlas/read/getPerformanceAdvisor.ts | 129 +++++ src/tools/atlas/tools.ts | 2 + tests/accuracy/getPerformanceAdvisor.test.ts | 165 ++++++ tests/accuracy/sdk/accuracyTestingClient.ts | 16 +- tests/accuracy/sdk/describeAccuracyTests.ts | 9 +- tests/integration/helpers.ts | 2 + tests/integration/tools/atlas/atlasHelpers.ts | 91 +++- .../integration/tools/atlas/clusters.test.ts | 71 +-- .../tools/atlas/performanceAdvisor.test.ts | 231 ++++++++ 17 files changed, 1413 insertions(+), 68 deletions(-) create mode 100644 src/common/atlas/performanceAdvisorUtils.ts create mode 100644 src/tools/atlas/read/getPerformanceAdvisor.ts create mode 100644 tests/accuracy/getPerformanceAdvisor.test.ts create mode 100644 tests/integration/tools/atlas/performanceAdvisor.test.ts 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/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..11549039c --- /dev/null +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -0,0 +1,153 @@ +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; + +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/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/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..120c765af --- /dev/null +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -0,0 +1,129 @@ +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, +} 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 + ? `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.\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 ? 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/tests/accuracy/getPerformanceAdvisor.test.ts b/tests/accuracy/getPerformanceAdvisor.test.ts new file mode 100644 index 000000000..62b570c12 --- /dev/null +++ b/tests/accuracy/getPerformanceAdvisor.test.ts @@ -0,0 +1,165 @@ +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", + }, + ], + }; + }, +}; + +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: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + 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: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + 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, 2025.", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["slowQueryLogs"], + namespaces: ["mflix.movies", "mflix.shows"], + since: "2025-01-01T00:00:00Z", + }, + }, + ], + mockedTools, + }, + // Test for Schema Suggestions operation + { + prompt: "Give me schema suggestions for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + 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: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + }, + }, + ], + mockedTools, + }, +]); diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 692694aa7..130e8fb05 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -79,10 +79,22 @@ export class AccuracyTestingClient { this.llmToolCalls = []; } - static async initializeClient(mdbConnectionString: string): Promise { + static async initializeClient( + mdbConnectionString: string, + atlasApiClientId?: string, + atlasApiClientSecret?: string + ): Promise { + const args = [ + MCP_SERVER_CLI_SCRIPT, + "--connectionString", + mdbConnectionString, + ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), + ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), + ]; + 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 8073518b4..df35e3a03 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -66,6 +66,9 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) const mdbIntegration = setupMongoDBIntegrationTest(); const { populateTestData, cleanupTestDatabases } = prepareTestData(mdbIntegration); + const atlasApiClientId = process.env.MDB_MCP_API_CLIENT_ID; + const atlasApiClientSecret = process.env.MDB_MCP_API_CLIENT_SECRET; + let commitSHA: string; let accuracyResultStorage: AccuracyResultStorage; let testMCPClient: AccuracyTestingClient; @@ -79,7 +82,11 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) commitSHA = retrievedCommitSHA; accuracyResultStorage = getAccuracyResultStorage(); - testMCPClient = await AccuracyTestingClient.initializeClient(mdbIntegration.connectionString()); + testMCPClient = await AccuracyTestingClient.initializeClient( + mdbIntegration.connectionString(), + atlasApiClientId, + atlasApiClientSecret + ); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 6282851cf..0f510beca 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -48,6 +48,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, diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 13f160c79..a9f24cb49 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; @@ -150,3 +151,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 0c7b75596..543988c47 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,67 +1,16 @@ import type { Session } from "../../../../src/common/session.js"; -import { expectDefined, getDataFromUntrustedContent, getResponseElements, sleep } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js"; -import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.js"; +import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; +import { + describeWithAtlas, + withProject, + randomId, + parseTable, + deleteCluster, + waitCluster, + sleep, +} from "./atlasHelpers.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -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/performanceAdvisor.test.ts b/tests/integration/tools/atlas/performanceAdvisor.test.ts new file mode 100644 index 000000000..bb3753485 --- /dev/null +++ b/tests/integration/tools/atlas/performanceAdvisor.test.ts @@ -0,0 +1,231 @@ +// This test file includes long running tests (>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(" Date: Thu, 9 Oct 2025 09:07:08 +0100 Subject: [PATCH 10/28] chore: use redact by default (#624) --- src/server.ts | 2 +- src/telemetry/telemetry.ts | 12 +++-- src/telemetry/types.ts | 4 +- tests/unit/telemetry.test.ts | 89 ++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index 794fb9867..f8aa3226b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -201,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 || []; } 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/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(""); + }); + }); + }); }); From bebfd338617a0a6b62082331cc79e9ba4d01313f Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Thu, 9 Oct 2025 15:31:44 +0200 Subject: [PATCH 11/28] fix: update tool category for collection-indexes (#630) --- README.md | 2 +- .../{read => metadata}/collectionIndexes.ts | 2 +- src/tools/mongodb/tools.ts | 2 +- src/tools/tool.ts | 34 +++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) rename src/tools/mongodb/{read => metadata}/collectionIndexes.ts (97%) diff --git a/README.md b/README.md index 469e33754..092e5f276 100644 --- a/README.md +++ b/README.md @@ -418,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/src/tools/mongodb/read/collectionIndexes.ts b/src/tools/mongodb/metadata/collectionIndexes.ts similarity index 97% rename from src/tools/mongodb/read/collectionIndexes.ts rename to src/tools/mongodb/metadata/collectionIndexes.ts index 84b8b1dbb..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(); diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index 1567fd4f8..4c705fa69 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"; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index fe36619e3..d609e78a8 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -15,8 +15,34 @@ export type ToolCallbackArgs = Parameters = Parameters>[1]; +/** + * 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; @@ -290,6 +316,14 @@ export abstract class ToolBase { } } +/** + * 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(); From 5af0d3d567ee1dd4dcaf2b07071e861822866c21 Mon Sep 17 00:00:00 2001 From: Bianca Lisle <40155621+blva@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:29:50 +0100 Subject: [PATCH 12/28] chore: add docker dependabot updates - MCP-257 (#633) --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c86fb00b..05b63ec6b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,3 +24,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" From 9d13e6b4d3bc56f1b466be07a9769892133955a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:42:05 +0100 Subject: [PATCH 13/28] chore(deps): bump github/codeql-action from 3 to 4 (#634) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}}" From 695031e6303d7236b8f4521a32bcd13d1eb4014f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:39:58 +0200 Subject: [PATCH 14/28] chore(deps-dev): bump @typescript-eslint/parser from 8.44.1 to 8.46.0 (#638) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 196 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 189 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a6a7d015..2f1376530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5637,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": { @@ -5661,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", @@ -15386,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", From b69c3797d896e3d02bb22b6bd70c6b06f236fc60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:40:15 +0200 Subject: [PATCH 15/28] chore(deps-dev): bump @types/node from 24.5.2 to 24.7.0 (#637) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f1376530..a8c87c32c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5444,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.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", + "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/proper-lockfile": { @@ -15647,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" }, From be54b362c49aba85aca9215efcae16241447e876 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:42:10 +0200 Subject: [PATCH 16/28] chore(deps): bump node from 22-alpine to 24-alpine (#636) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From 83f34b1ad4973f46673857f8b9746cc928debcbe Mon Sep 17 00:00:00 2001 From: "mongodb-devtools-bot[bot]" <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:51:42 +0000 Subject: [PATCH 17/28] chore: release v1.1.0-prerelease.1 (#643) Co-authored-by: mongodb-devtools-bot[bot] <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- src/common/packageInfo.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8c87c32c..811b26e46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-mcp-server", - "version": "1.0.3-prerelease.1", + "version": "1.1.0-prerelease.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-mcp-server", - "version": "1.0.3-prerelease.1", + "version": "1.1.0-prerelease.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", diff --git a/package.json b/package.json index 2801da8de..8b2e7b8be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mongodb-mcp-server", "description": "MongoDB Model Context Protocol Server", - "version": "1.0.3-prerelease.1", + "version": "1.1.0-prerelease.1", "type": "module", "exports": { ".": { diff --git a/src/common/packageInfo.ts b/src/common/packageInfo.ts index b65b24839..37d426ec5 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.3-prerelease.1", + version: "1.1.0-prerelease.1", mcpServerName: "MongoDB MCP Server", }; From faad36d48d7d7968df509c730188898f29fd6e0f Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Mon, 13 Oct 2025 23:07:13 +0200 Subject: [PATCH 18/28] chore: method to detect if a cluster support search indexes and expose the same on debug resource MCP-247 MCP-248 (#628) --- src/common/session.ts | 13 ++++++ src/resources/common/debug.ts | 8 ++-- src/resources/resource.ts | 6 +-- tests/unit/common/session.test.ts | 28 +++++++++++- tests/unit/resources/common/debug.test.ts | 54 ++++++++++++++--------- 5 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/common/session.ts b/src/common/session.ts index 3c702a645..4ec536f4e 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -153,4 +153,17 @@ export class Session extends EventEmitter { get connectedAtlasCluster(): AtlasClusterConnectionInfo | undefined { return this.connectionManager.currentConnectionState.connectedAtlasCluster; } + + async isSearchIndexSupported(): Promise { + try { + const dummyDatabase = `search-index-test-db-${Date.now()}`; + const dummyCollection = `search-index-test-coll-${Date.now()}`; + // 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); + return true; + } catch { + return false; + } + } } diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts index ad1f383df..f76030b5a 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.isSearchIndexSupported(); + 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/tests/unit/common/session.test.ts b/tests/unit/common/session.test.ts index 9402df246..ea6ac348b 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.isSearchIndexSupported()).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.isSearchIndexSupported()).toEqual(false); + }); + }); }); diff --git a/tests/unit/resources/common/debug.test.ts b/tests/unit/resources/common/debug.test.ts index f031fd218..3a4c68e27 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 () => { + session.isSearchIndexSupported = vi.fn().mockResolvedValue(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.`); + }); }); From 7e80114c8ecbefbe345f1db721f7c2ee7f35c54a Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 14 Oct 2025 11:48:36 +0200 Subject: [PATCH 19/28] feat: introduces drop-index tool for regular index (#644) --- src/common/config.ts | 1 + src/tools/mongodb/delete/dropIndex.ts | 45 +++++ src/tools/mongodb/tools.ts | 2 + tests/accuracy/dropIndex.test.ts | 74 +++++++ tests/accuracy/sdk/accuracyTestingClient.ts | 4 +- .../tools/mongodb/delete/dropIndex.test.ts | 181 ++++++++++++++++++ tests/integration/transports/stdio.test.ts | 2 +- 7 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 src/tools/mongodb/delete/dropIndex.ts create mode 100644 tests/accuracy/dropIndex.test.ts create mode 100644 tests/integration/tools/mongodb/delete/dropIndex.test.ts diff --git a/src/common/config.ts b/src/common/config.ts index efcc7b4a6..8a32df931 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -201,6 +201,7 @@ export const defaultUserConfig: UserConfig = { "drop-database", "drop-collection", "delete-many", + "drop-index", ], transport: "stdio", httpPort: 3000, 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/tools.ts b/src/tools/mongodb/tools.ts index 4c705fa69..6e96b2ba6 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -20,12 +20,14 @@ 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, diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts new file mode 100644 index 000000000..48023af55 --- /dev/null +++ b/tests/accuracy/dropIndex.test.ts @@ -0,0 +1,74 @@ +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()), + keys: { + title: "text", + }, + }, + }, + { + 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/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 130e8fb05..3e5b89b78 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -7,7 +7,7 @@ import { MCP_SERVER_CLI_SCRIPT } from "./constants.js"; import type { LLMToolCall } from "./accuracyResultStorage/resultStorage.js"; import type { VercelMCPClient, VercelMCPClientTools } from "./agent.js"; -type ToolResultGeneratorFn = (...parameters: unknown[]) => CallToolResult | Promise; +type ToolResultGeneratorFn = (parameters: Record) => CallToolResult | Promise; export type MockedTools = Record; /** @@ -44,7 +44,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); 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..a1aac591e --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -0,0 +1,181 @@ +import { describe, beforeEach, it, afterEach, expect } from "vitest"; +import type { Collection } from "mongodb"; +import { + databaseCollectionInvalidArgs, + databaseCollectionParameters, + defaultDriverOptions, + defaultTestConfig, + getDataFromUntrustedContent, + getResponseContent, + setupIntegrationTest, + validateThrowsForInvalidArguments, + validateToolMetadata, +} from "../../../helpers.js"; +import { describeWithMongoDB, setupMongoDBIntegrationTest } 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" }); + }); + }); +}); + +describe("drop-index tool - when invoked via an elicitation enabled client", () => { + const mockElicitInput = createMockElicitInput(); + const mdbIntegration = setupMongoDBIntegrationTest(); + const integration = setupIntegrationTest( + () => defaultTestConfig, + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); + let moviesCollection: Collection; + let indexName: string; + + beforeEach(async () => { + moviesCollection = mdbIntegration.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: mdbIntegration.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); + }); +}); 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"); From 6ee63532abb271020cc2e1b694998731099be76f Mon Sep 17 00:00:00 2001 From: Bianca Lisle <40155621+blva@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:39:40 +0200 Subject: [PATCH 20/28] fix: cleanup script should group errors (#649) --- scripts/cleanupAtlasTestLeftovers.test.ts | 56 +++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/scripts/cleanupAtlasTestLeftovers.test.ts b/scripts/cleanupAtlasTestLeftovers.test.ts index 24351c8b6..e7e4ffefb 100644 --- a/scripts/cleanupAtlasTestLeftovers.test.ts +++ b/scripts/cleanupAtlasTestLeftovers.test.ts @@ -35,7 +35,9 @@ async function findAllTestProjects(client: ApiClient, orgId: string): Promise isOlderThanADay(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 { @@ -70,8 +80,11 @@ async function main(): Promise { 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 +92,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", () => { From 8e3c6a6501b289e6a9bd5302ce885aa042225ec4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:01:41 +0200 Subject: [PATCH 21/28] chore(deps-dev): bump @types/node from 24.7.0 to 24.7.2 (#646) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 811b26e46..15cef9f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5444,9 +5444,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", - "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", + "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": { From 9a2c00227f14db44aeaae8fb26f78d8fe8471a92 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 14 Oct 2025 21:20:36 +0200 Subject: [PATCH 22/28] chore: use describeWithMongoDB to setup test suites instead of custom wiring (#650) --- .../common/connectionManager.oidc.test.ts | 21 +- tests/integration/elicitation.test.ts | 505 +++++++++--------- tests/integration/indexCheck.test.ts | 30 +- .../resources/exportedData.test.ts | 4 +- tests/integration/server.test.ts | 123 ++--- .../tools/mongodb/connect/connect.test.ts | 145 +++-- .../tools/mongodb/delete/dropIndex.test.ts | 124 +++-- .../tools/mongodb/mongodbHelpers.ts | 27 +- .../tools/mongodb/read/aggregate.test.ts | 12 +- .../tools/mongodb/read/export.test.ts | 4 +- .../tools/mongodb/read/find.test.ts | 12 +- .../mongodb/search/listSearchIndexes.test.ts | 6 +- 12 files changed, 533 insertions(+), 480 deletions(-) diff --git a/tests/integration/common/connectionManager.oidc.test.ts b/tests/integration/common/connectionManager.oidc.test.ts index 9f30cf32e..3d949bc88 100644 --- a/tests/integration/common/connectionManager.oidc.test.ts +++ b/tests/integration/common/connectionManager.oidc.test.ts @@ -137,14 +137,19 @@ describe.skipIf(process.platform !== "linux")("ConnectionManager OIDC Tests", as addCb?.(oidcIt); }, - () => oidcConfig, - () => ({ - ...setupDriverConfig({ - config: oidcConfig, - defaults: {}, - }), - }), - { runner: true, downloadOptions: { 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/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/mongodb/connect/connect.test.ts b/tests/integration/tools/mongodb/connect/connect.test.ts index 46526fe5b..132e0fd90 100644 --- a/tests/integration/tools/mongodb/connect/connect.test.ts +++ b/tests/integration/tools/mongodb/connect/connect.test.ts @@ -1,13 +1,11 @@ import { describeWithMongoDB } from "../mongodbHelpers.js"; import { - defaultDriverOptions, getResponseContent, getResponseElements, validateThrowsForInvalidArguments, validateToolMetadata, } from "../../../helpers.js"; -import { config } from "../../../../../src/common/config.js"; -import { defaultTestConfig, setupIntegrationTest } from "../../../helpers.js"; +import { defaultTestConfig } from "../../../helpers.js"; import { beforeEach, describe, expect, it } from "vitest"; describeWithMongoDB( @@ -82,89 +80,88 @@ describeWithMongoDB( }); }); }, - (mdbIntegration) => ({ - ...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/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts index a1aac591e..46360b81b 100644 --- a/tests/integration/tools/mongodb/delete/dropIndex.test.ts +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -3,15 +3,12 @@ import type { Collection } from "mongodb"; import { databaseCollectionInvalidArgs, databaseCollectionParameters, - defaultDriverOptions, - defaultTestConfig, getDataFromUntrustedContent, getResponseContent, - setupIntegrationTest, validateThrowsForInvalidArguments, validateToolMetadata, } from "../../../helpers.js"; -import { describeWithMongoDB, setupMongoDBIntegrationTest } from "../mongodbHelpers.js"; +import { describeWithMongoDB } from "../mongodbHelpers.js"; import { createMockElicitInput } from "../../../../utils/elicitationMocks.js"; import { Elicitation } from "../../../../../src/elicitation.js"; @@ -113,69 +110,70 @@ describeWithMongoDB("drop-index tool", (integration) => { }); }); -describe("drop-index tool - when invoked via an elicitation enabled client", () => { - const mockElicitInput = createMockElicitInput(); - const mdbIntegration = setupMongoDBIntegrationTest(); - const integration = setupIntegrationTest( - () => defaultTestConfig, - () => defaultDriverOptions, - { elicitInput: mockElicitInput } - ); - let moviesCollection: Collection; - let indexName: string; +const mockElicitInput = createMockElicitInput(); - beforeEach(async () => { - moviesCollection = mdbIntegration.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: mdbIntegration.connectionString(), - }, - }); - }); - - afterEach(async () => { - await moviesCollection.drop(); - }); +describeWithMongoDB( + "drop-index tool - when invoked via an elicitation enabled client", + (integration) => { + let moviesCollection: Collection; + let indexName: string; - 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 }, + 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(), + }, + }); }); - 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, + + afterEach(async () => { + await moviesCollection.drop(); }); - 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 }, + 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); }); - 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, + + 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); }); - expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); - }); -}); + }, + { + getMockElicitationInput: () => mockElicitInput, + } +); diff --git a/tests/integration/tools/mongodb/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index e3a332ae8..7c6da4874 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -16,6 +16,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { EJSON } from "bson"; import { MongoDBClusterProcess } from "./mongodbClusterProcess.js"; import type { MongoClusterConfiguration } from "./mongodbClusterProcess.js"; +import type { createMockElicitInput, MockClientCapabilities } from "../../../utils/elicitationMocks.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -66,22 +67,40 @@ export type MongoDBIntegrationTestCase = IntegrationTest & 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: MongoClusterConfiguration = DEFAULT_MONGODB_PROCESS_OPTIONS + partialTestSuiteConfig?: Partial ): void { + 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({ 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 index 97571c0a9..fa69fa721 100644 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -117,9 +117,9 @@ describeWithMongoDB( ); }); }, - undefined, // default user config - undefined, // default driver config - { search: true } // use a search cluster + { + downloadOptions: { search: true }, + } ); async function waitUntilSearchIsReady(provider: NodeDriverServiceProvider, abortSignal: AbortSignal): Promise { From a7720fe41945c1ff4a5525547937846d67d4284b Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 15 Oct 2025 13:45:53 +0200 Subject: [PATCH 23/28] feat: add ability to create vector search indexes MCP-234 (#621) --- src/common/config.ts | 4 + src/common/connectionManager.ts | 53 +- src/common/session.ts | 22 +- src/resources/common/debug.ts | 2 +- src/tools/mongodb/create/createIndex.ts | 140 +++- src/tools/mongodb/mongodbTool.ts | 2 +- src/tools/tool.ts | 14 + tests/accuracy/createIndex.test.ts | 126 +++- tests/accuracy/dropIndex.test.ts | 11 +- tests/accuracy/sdk/accuracyTestingClient.ts | 4 +- tests/accuracy/sdk/describeAccuracyTests.ts | 4 +- tests/integration/helpers.ts | 51 +- .../tools/mongodb/create/createIndex.test.ts | 628 +++++++++++++----- .../tools/mongodb/mongodbHelpers.ts | 56 ++ .../mongodb/search/listSearchIndexes.test.ts | 59 +- tests/unit/common/session.test.ts | 4 +- tests/unit/resources/common/debug.test.ts | 2 +- 17 files changed, 906 insertions(+), 276 deletions(-) diff --git a/src/common/config.ts b/src/common/config.ts index 8a32df931..b7bf527b1 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -183,6 +183,8 @@ export interface UserConfig extends CliOptions { maxBytesPerQuery: number; atlasTemporaryDatabaseUserLifetimeMs: number; voyageApiKey: string; + vectorSearchDimensions: number; + vectorSearchSimilarityFunction: "cosine" | "euclidean" | "dotProduct"; } export const defaultUserConfig: UserConfig = { @@ -214,6 +216,8 @@ export const defaultUserConfig: UserConfig = { 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/session.ts b/src/common/session.ts index 4ec536f4e..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; @@ -153,17 +162,4 @@ export class Session extends EventEmitter { get connectedAtlasCluster(): AtlasClusterConnectionInfo | undefined { return this.connectionManager.currentConnectionState.connectedAtlasCluster; } - - async isSearchIndexSupported(): Promise { - try { - const dummyDatabase = `search-index-test-db-${Date.now()}`; - const dummyCollection = `search-index-test-coll-${Date.now()}`; - // 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); - return true; - } catch { - return false; - } - } } diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts index f76030b5a..29bc26401 100644 --- a/src/resources/common/debug.ts +++ b/src/resources/common/debug.ts @@ -61,7 +61,7 @@ export class DebugResource extends ReactiveResource< switch (this.current.tag) { case "connected": { - const searchIndexesSupported = await this.session.isSearchIndexSupported(); + 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; } 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/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/tool.ts b/src/tools/tool.ts index d609e78a8..bb7e872c4 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -15,6 +15,10 @@ 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. @@ -314,6 +318,16 @@ 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; + } + } } /** diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index 08326ce31..becd5b464 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,6 +1,20 @@ +import { afterAll, beforeAll } from "vitest"; import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; +let originalApiKey: string | undefined; +beforeAll(() => { + originalApiKey = process.env.MDB_VOYAGE_API_KEY; + + // We just need a valid key when registering the tool, the actual value is not important + if (!originalApiKey) { + process.env.MDB_VOYAGE_API_KEY = "valid-key"; + } +}); +afterAll(() => { + process.env.MDB_VOYAGE_API_KEY = originalApiKey; +}); + describeAccuracyTests([ { prompt: "Create an index that covers the following query on 'mflix.movies' namespace - { \"release_year\": 1992 }", @@ -11,9 +25,14 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - release_year: 1, - }, + definition: [ + { + type: "classic", + keys: { + release_year: 1, + }, + }, + ], }, }, ], @@ -27,9 +46,104 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - title: "text", - }, + definition: [ + { + type: "classic", + keys: { + title: "text", + }, + }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mydb.movies' namespace on the 'plotSummary' field. The index should use 1024 dimensions.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mydb", + 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 'mydb.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: "mydb", + 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 'mydb.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: "mydb", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: 1024, + }, + { + type: "filter", + path: "releaseDate", + }, + ], + }, + ], }, }, ], diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts index 48023af55..82e760756 100644 --- a/tests/accuracy/dropIndex.test.ts +++ b/tests/accuracy/dropIndex.test.ts @@ -40,9 +40,14 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - title: "text", - }, + definition: [ + { + keys: { + title: "text", + }, + type: "classic", + }, + ], }, }, { diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 3e5b89b78..48cba3b2c 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -82,7 +82,8 @@ export class AccuracyTestingClient { static async initializeClient( mdbConnectionString: string, atlasApiClientId?: string, - atlasApiClientSecret?: string + atlasApiClientSecret?: string, + voyageApiKey?: string ): Promise { const args = [ MCP_SERVER_CLI_SCRIPT, @@ -90,6 +91,7 @@ export class AccuracyTestingClient { mdbConnectionString, ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), + ...(voyageApiKey ? ["--voyageApiKey", voyageApiKey] : []), ]; const clientTransport = new StdioClientTransport({ diff --git a/tests/accuracy/sdk/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index df35e3a03..4c39e9623 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -68,6 +68,7 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) const atlasApiClientId = process.env.MDB_MCP_API_CLIENT_ID; const atlasApiClientSecret = process.env.MDB_MCP_API_CLIENT_SECRET; + const voyageApiKey = process.env.MDB_VOYAGE_API_KEY; let commitSHA: string; let accuracyResultStorage: AccuracyResultStorage; @@ -85,7 +86,8 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) testMCPClient = await AccuracyTestingClient.initializeClient( mdbIntegration.connectionString(), atlasApiClientId, - atlasApiClientSecret + atlasApiClientSecret, + voyageApiKey ); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 0f510beca..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 { @@ -219,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, }; }); } 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/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index 7c6da4874..579598646 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -10,12 +10,14 @@ 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)); @@ -278,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/search/listSearchIndexes.test.ts b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts index fa69fa721..477f9faee 100644 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -1,4 +1,9 @@ -import { describeWithMongoDB, getSingleDocFromUntrustedContent } from "../mongodbHelpers.js"; +import { + describeWithMongoDB, + getSingleDocFromUntrustedContent, + waitUntilSearchIndexIsQueryable, + waitUntilSearchIsReady, +} from "../mongodbHelpers.js"; import { describe, it, expect, beforeEach } from "vitest"; import { getResponseContent, @@ -6,13 +11,11 @@ import { validateToolMetadata, validateThrowsForInvalidArguments, databaseCollectionInvalidArgs, - sleep, 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_RETRIES = 200; const SEARCH_TIMEOUT = 20_000; describeWithMongoDB("list search indexes tool in local MongoDB", (integration) => { @@ -98,7 +101,7 @@ describeWithMongoDB( "returns the list of existing indexes and detects if they are queryable", { timeout: SEARCH_TIMEOUT }, async ({ signal }) => { - await waitUntilIndexIsQueryable(provider, "any", "foo", "default", signal); + await waitUntilSearchIndexIsQueryable(provider, "any", "foo", "default", signal); const response = await integration.mcpClient().callTool({ name: "list-search-indexes", @@ -121,51 +124,3 @@ describeWithMongoDB( downloadOptions: { search: true }, } ); - -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 } } }]); - return; - } catch (err) { - lastError = err; - await sleep(100); - } - } - - throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); -} - -async function waitUntilIndexIsQueryable( - 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/unit/common/session.test.ts b/tests/unit/common/session.test.ts index ea6ac348b..7b3176113 100644 --- a/tests/unit/common/session.test.ts +++ b/tests/unit/common/session.test.ts @@ -134,7 +134,7 @@ describe("Session", () => { await session.connectToMongoDB({ connectionString: "mongodb://localhost:27017", }); - expect(await session.isSearchIndexSupported()).toEqual(true); + expect(await session.isSearchSupported()).toEqual(true); }); it("should return false if listing search indexes fail with search error", async () => { @@ -142,7 +142,7 @@ describe("Session", () => { await session.connectToMongoDB({ connectionString: "mongodb://localhost:27017", }); - expect(await session.isSearchIndexSupported()).toEqual(false); + 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 3a4c68e27..56b1409d9 100644 --- a/tests/unit/resources/common/debug.test.ts +++ b/tests/unit/resources/common/debug.test.ts @@ -103,7 +103,7 @@ describe("debug resource", () => { }); it("should notify if a cluster supports search indexes", async () => { - session.isSearchIndexSupported = vi.fn().mockResolvedValue(true); + vi.spyOn(session, "isSearchSupported").mockImplementation(() => Promise.resolve(true)); debugResource.reduceApply("connect", undefined); const output = await debugResource.toOutput(); From 18fe5495cea00cd3de484077d1e3711ca0a9389e Mon Sep 17 00:00:00 2001 From: Bianca Lisle <40155621+blva@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:47:29 +0200 Subject: [PATCH 24/28] chore: reduce cleanup time (#652) --- scripts/cleanupAtlasTestLeftovers.test.ts | 13 +++++--- tests/integration/tools/atlas/atlasHelpers.ts | 14 ++++++-- tests/integration/tools/atlas/orgs.test.ts | 32 ++++++++++--------- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/scripts/cleanupAtlasTestLeftovers.test.ts b/scripts/cleanupAtlasTestLeftovers.test.ts index e7e4ffefb..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,7 +32,7 @@ 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 { @@ -76,8 +76,11 @@ 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; diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index a9f24cb49..2a8fce123 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -18,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 @@ -35,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 = ""; 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(" Date: Wed, 15 Oct 2025 16:19:24 +0200 Subject: [PATCH 25/28] fix: accuracy test fixes (#651) --- scripts/accuracy/generateTestSummary.ts | 3 +- tests/accuracy/createCollection.test.ts | 5 ++ tests/accuracy/createIndex.test.ts | 15 +--- tests/accuracy/dropCollection.test.ts | 12 +++ tests/accuracy/dropDatabase.test.ts | 5 ++ tests/accuracy/export.test.ts | 20 +++-- tests/accuracy/find.test.ts | 8 +- tests/accuracy/getPerformanceAdvisor.test.ts | 74 ++++++------------- .../accuracyResultStorage/resultStorage.ts | 4 +- tests/accuracy/sdk/accuracyScorer.ts | 13 ++-- 10 files changed, 80 insertions(+), 79 deletions(-) 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/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 becd5b464..f3c600eaf 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,19 +1,8 @@ -import { afterAll, beforeAll } from "vitest"; import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; -let originalApiKey: string | undefined; -beforeAll(() => { - originalApiKey = process.env.MDB_VOYAGE_API_KEY; - - // We just need a valid key when registering the tool, the actual value is not important - if (!originalApiKey) { - process.env.MDB_VOYAGE_API_KEY = "valid-key"; - } -}); -afterAll(() => { - process.env.MDB_VOYAGE_API_KEY = originalApiKey; -}); +// TODO: supply this with a proper config API once we refactor describeAccuracyTests to support it +process.env.MDB_VOYAGE_API_KEY = "valid-key"; describeAccuracyTests([ { 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/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 index 62b570c12..02b61b33f 100644 --- a/tests/accuracy/getPerformanceAdvisor.test.ts +++ b/tests/accuracy/getPerformanceAdvisor.test.ts @@ -35,21 +35,27 @@ const mockedTools = { }, }; +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: [ - { - toolName: "atlas-list-projects", - parameters: {}, - }, - { - toolName: "atlas-list-clusters", - parameters: { - projectId: "mflix", - }, - }, + ...listProjectsAndClustersToolCalls, { toolName: "atlas-get-performance-advisor", parameters: { @@ -65,16 +71,7 @@ describeAccuracyTests([ { prompt: "Show me drop index suggestions for the 'mflix' project and 'mflix-cluster' cluster", expectedToolCalls: [ - { - toolName: "atlas-list-projects", - parameters: {}, - }, - { - toolName: "atlas-list-clusters", - parameters: { - projectId: "mflix", - }, - }, + ...listProjectsAndClustersToolCalls, { toolName: "atlas-get-performance-advisor", parameters: { @@ -88,18 +85,9 @@ describeAccuracyTests([ }, // 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, 2025.", + 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: [ - { - toolName: "atlas-list-projects", - parameters: {}, - }, - { - toolName: "atlas-list-clusters", - parameters: { - projectId: "mflix", - }, - }, + ...listProjectsAndClustersToolCalls, { toolName: "atlas-get-performance-advisor", parameters: { @@ -107,7 +95,7 @@ describeAccuracyTests([ clusterName: "mflix-cluster", operations: ["slowQueryLogs"], namespaces: ["mflix.movies", "mflix.shows"], - since: "2025-01-01T00:00:00Z", + since: "2023-01-01T00:00:00Z", }, }, ], @@ -117,16 +105,7 @@ describeAccuracyTests([ { prompt: "Give me schema suggestions for the 'mflix' project and 'mflix-cluster' cluster", expectedToolCalls: [ - { - toolName: "atlas-list-projects", - parameters: {}, - }, - { - toolName: "atlas-list-clusters", - parameters: { - projectId: "mflix", - }, - }, + ...listProjectsAndClustersToolCalls, { toolName: "atlas-get-performance-advisor", parameters: { @@ -142,16 +121,7 @@ describeAccuracyTests([ { prompt: "Show me all performance advisor recommendations for the 'mflix' project and 'mflix-cluster' cluster", expectedToolCalls: [ - { - toolName: "atlas-list-projects", - parameters: {}, - }, - { - toolName: "atlas-list-clusters", - parameters: { - projectId: "mflix", - }, - }, + ...listProjectsAndClustersToolCalls, { toolName: "atlas-get-performance-advisor", parameters: { 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; From a8c6425a8d6f04f87f90acca2395c592407c86eb Mon Sep 17 00:00:00 2001 From: Kyle Lai <122811196+kylelai1@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:13:03 -0400 Subject: [PATCH 26/28] feat: atlas-get-performance-advisor tool: tweak language for slow queries (#645) --- src/common/atlas/performanceAdvisorUtils.ts | 3 +++ src/tools/atlas/read/getPerformanceAdvisor.ts | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 11549039c..61a79d188 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -9,6 +9,9 @@ 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"]; } diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts index 120c765af..fa7ec5194 100644 --- a/src/tools/atlas/read/getPerformanceAdvisor.ts +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -9,6 +9,8 @@ import { getSchemaAdvice, getSlowQueries, DEFAULT_SLOW_QUERY_LOGS_LIMIT, + SUGGESTED_INDEXES_COPY, + SLOW_QUERY_LOGS_COPY, } from "../../../common/atlas/performanceAdvisorUtils.js"; import { AtlasArgs } from "../../args.js"; @@ -98,11 +100,11 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { const performanceAdvisorData = [ `## Suggested Indexes\n${ hasSuggestedIndexes - ? `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.\n${JSON.stringify(suggestedIndexesResult.value?.suggestedIndexes)}` + ? `${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 ? JSON.stringify(slowQueryLogsResult.value?.slowQueryLogs) : "No slow query logs 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."}`, ]; From 87ce0cf2d18b8ff664720fbaf34a6fd457fba3dc Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Thu, 16 Oct 2025 08:13:05 +0200 Subject: [PATCH 27/28] chore: extend accuracy tests to support custom user and cluster config (#655) --- tests/accuracy/createIndex.test.ts | 265 +++++++++--------- .../createIndex.vectorSearchDisabled.test.ts | 57 ++++ tests/accuracy/sdk/accuracyTestingClient.ts | 18 +- tests/accuracy/sdk/describeAccuracyTests.ts | 35 ++- .../tools/mongodb/mongodbClusterProcess.ts | 3 +- 5 files changed, 224 insertions(+), 154 deletions(-) create mode 100644 tests/accuracy/createIndex.vectorSearchDisabled.test.ts diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index f3c600eaf..66f330148 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,140 +1,145 @@ import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; -// TODO: supply this with a proper config API once we refactor describeAccuracyTests to support it -process.env.MDB_VOYAGE_API_KEY = "valid-key"; - -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, +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()), - definition: [ - { - type: "classic", - 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 'mydb.movies' namespace on the 'plotSummary' field. The index should use 1024 dimensions.", - expectedToolCalls: [ - { - toolName: "create-index", - parameters: { - database: "mydb", - 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 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 'mydb.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: "mydb", - 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 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 'mydb.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: "mydb", - collection: "movies", - name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - definition: [ - { - type: "vectorSearch", - fields: [ - { - type: "vector", - path: "plotSummary", - numDimensions: 1024, - }, - { - type: "filter", - path: "releaseDate", - }, - ], - }, - ], + ], + }, + { + 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/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 48cba3b2c..6ebed6878 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -6,6 +6,7 @@ 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: Record) => CallToolResult | Promise; export type MockedTools = Record; @@ -81,18 +82,13 @@ export class AccuracyTestingClient { static async initializeClient( mdbConnectionString: string, - atlasApiClientId?: string, - atlasApiClientSecret?: string, - voyageApiKey?: string + userConfig: Partial<{ [k in keyof UserConfig]: string }> = {} ): Promise { - const args = [ - MCP_SERVER_CLI_SCRIPT, - "--connectionString", - mdbConnectionString, - ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), - ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), - ...(voyageApiKey ? ["--voyageApiKey", voyageApiKey] : []), - ]; + 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, diff --git a/tests/accuracy/sdk/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index 4c39e9623..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,17 +69,22 @@ 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 atlasApiClientId = process.env.MDB_MCP_API_CLIENT_ID; - const atlasApiClientSecret = process.env.MDB_MCP_API_CLIENT_SECRET; - const voyageApiKey = process.env.MDB_VOYAGE_API_KEY; + 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; @@ -83,12 +99,7 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) commitSHA = retrievedCommitSHA; accuracyResultStorage = getAccuracyResultStorage(); - testMCPClient = await AccuracyTestingClient.initializeClient( - mdbIntegration.connectionString(), - atlasApiClientId, - atlasApiClientSecret, - voyageApiKey - ); + testMCPClient = await AccuracyTestingClient.initializeClient(mdbIntegration.connectionString(), userConfig); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/tools/mongodb/mongodbClusterProcess.ts b/tests/integration/tools/mongodb/mongodbClusterProcess.ts index b0f7ee863..bd0da659f 100644 --- a/tests/integration/tools/mongodb/mongodbClusterProcess.ts +++ b/tests/integration/tools/mongodb/mongodbClusterProcess.ts @@ -27,7 +27,8 @@ export class MongoDBClusterProcess { return new MongoDBClusterProcess( () => runningContainer.stop(), - () => `mongodb://${runningContainer.getHost()}:${runningContainer.getMappedPort(27017)}` + () => + `mongodb://${runningContainer.getHost()}:${runningContainer.getMappedPort(27017)}/?directConnection=true` ); } else if (MongoDBClusterProcess.isMongoRunnerOptions(config)) { const { downloadOptions, serverArgs } = config; From fdedb0238f2bb54d283432ff354e98216d5fa573 Mon Sep 17 00:00:00 2001 From: "mongodb-devtools-bot[bot]" <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:27:58 +0100 Subject: [PATCH 28/28] chore: release v1.1.0 (#657) Co-authored-by: mongodb-devtools-bot[bot] <189715634+mongodb-devtools-bot[bot]@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- src/common/packageInfo.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15cef9f64..6b7ee0e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-mcp-server", - "version": "1.1.0-prerelease.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-mcp-server", - "version": "1.1.0-prerelease.1", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", diff --git a/package.json b/package.json index 8b2e7b8be..659d67283 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mongodb-mcp-server", "description": "MongoDB Model Context Protocol Server", - "version": "1.1.0-prerelease.1", + "version": "1.1.0", "type": "module", "exports": { ".": { diff --git a/src/common/packageInfo.ts b/src/common/packageInfo.ts index 37d426ec5..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.1.0-prerelease.1", + version: "1.1.0", mcpServerName: "MongoDB MCP Server", };