diff --git a/src/server.ts b/src/server.ts index f8aa3226..bfef7c45 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, ToolConstructorParams } from "./tools/tool.js"; +import type { ToolBase, ToolCategory, ToolConstructorParams } from "./tools/tool.js"; import { validateConnectionString } from "./helpers/connectionOptions.js"; import { packageInfo } from "./common/packageInfo.js"; import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js"; @@ -174,6 +174,10 @@ export class Server { this.mcpServer.sendResourceListChanged(); } + public isToolCategoryAvailable(name: ToolCategory): boolean { + return !!this.tools.filter((t) => t.category === name).length; + } + public sendResourceUpdated(uri: string): void { this.session.logger.info({ id: LogId.resourceUpdateFailure, diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts index e87db417..e69d92dc 100644 --- a/src/tools/mongodb/delete/dropIndex.ts +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -1,7 +1,9 @@ import z from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; -import { type ToolArgs, type OperationType, formatUntrustedData } from "../../tool.js"; +import { type ToolArgs, type OperationType, formatUntrustedData, FeatureFlags } from "../../tool.js"; +import { ListSearchIndexesTool } from "../search/listSearchIndexes.js"; export class DropIndexTool extends MongoDBToolBase { public name = "drop-index"; @@ -9,15 +11,33 @@ export class DropIndexTool extends MongoDBToolBase { protected argsShape = { ...DbOperationArgs, indexName: z.string().nonempty().describe("The name of the index to be dropped."), + type: this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) + ? z + .enum(["classic", "search"]) + .describe( + "The type of index to be deleted. Use 'classic' for standard indexes and 'search' for atlas search and vector search indexes." + ) + : z + .literal("classic") + .default("classic") + .describe("The type of index to be deleted. Is always set to 'classic'."), }; public operationType: OperationType = "delete"; - protected async execute({ - database, - collection, - indexName, - }: ToolArgs): Promise { + protected async execute(toolArgs: ToolArgs): Promise { const provider = await this.ensureConnected(); + switch (toolArgs.type) { + case "classic": + return this.dropClassicIndex(provider, toolArgs); + case "search": + return this.dropSearchIndex(provider, toolArgs); + } + } + + private async dropClassicIndex( + provider: NodeDriverServiceProvider, + { database, collection, indexName }: ToolArgs + ): Promise { const result = await provider.runCommand(database, { dropIndexes: collection, index: indexName, @@ -35,9 +55,43 @@ export class DropIndexTool extends MongoDBToolBase { }; } - protected getConfirmationMessage({ database, collection, indexName }: ToolArgs): string { + private async dropSearchIndex( + provider: NodeDriverServiceProvider, + { database, collection, indexName }: ToolArgs + ): Promise { + await this.ensureSearchIsSupported(); + const searchIndexes = await ListSearchIndexesTool.getSearchIndexes(provider, database, collection); + const indexDoesNotExist = !searchIndexes.find((index) => index.name === indexName); + if (indexDoesNotExist) { + return { + content: formatUntrustedData( + "Index does not exist in the provided namespace.", + JSON.stringify({ indexName, namespace: `${database}.${collection}` }) + ), + isError: true, + }; + } + + await provider.dropSearchIndex(database, collection, indexName); + return { + content: formatUntrustedData( + "Successfully dropped the index from the provided namespace.", + JSON.stringify({ + indexName, + namespace: `${database}.${collection}`, + }) + ), + }; + } + + protected getConfirmationMessage({ + database, + collection, + indexName, + type, + }: ToolArgs): string { return ( - `You are about to drop the \`${indexName}\` index from the \`${database}.${collection}\` namespace:\n\n` + + `You are about to drop the ${type === "search" ? "search index" : "index"} named \`${indexName}\` 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/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index ce4ce604..a18599b8 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -87,7 +87,7 @@ export abstract class MongoDBToolBase extends ToolBase { isError: true, }; case ErrorCodes.AtlasSearchNotSupported: { - const CTA = this.isToolCategoryAvailable("atlas-local" as unknown as ToolCategory) + const CTA = this.server?.isToolCategoryAvailable("atlas-local" as unknown as ToolCategory) ? "`atlas-local` tools" : "Atlas CLI"; return { @@ -123,8 +123,4 @@ export abstract class MongoDBToolBase extends ToolBase { return metadata; } - - protected isToolCategoryAvailable(name: ToolCategory): boolean { - return (this.server?.tools.filter((t) => t.category === name).length ?? 0) > 0; - } } diff --git a/src/tools/mongodb/search/listSearchIndexes.ts b/src/tools/mongodb/search/listSearchIndexes.ts index 9eae7307..39af7685 100644 --- a/src/tools/mongodb/search/listSearchIndexes.ts +++ b/src/tools/mongodb/search/listSearchIndexes.ts @@ -1,10 +1,11 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; 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 = { +export type SearchIndexWithStatus = { name: string; type: "search" | "vectorSearch"; status: string; @@ -21,15 +22,13 @@ export class ListSearchIndexesTool extends MongoDBToolBase { protected async execute({ database, collection }: ToolArgs): Promise { const provider = await this.ensureConnected(); await this.ensureSearchIsSupported(); + const searchIndexes = await ListSearchIndexesTool.getSearchIndexes(provider, database, collection); - const indexes = await provider.getSearchIndexes(database, collection); - const trimmedIndexDefinitions = this.pickRelevantInformation(indexes); - - if (trimmedIndexDefinitions.length > 0) { + if (searchIndexes.length > 0) { return { content: formatUntrustedData( - `Found ${trimmedIndexDefinitions.length} search and vector search indexes in ${database}.${collection}`, - ...trimmedIndexDefinitions.map((index) => EJSON.stringify(index)) + `Found ${searchIndexes.length} search and vector search indexes in ${database}.${collection}`, + ...searchIndexes.map((index) => EJSON.stringify(index)) ), }; } else { @@ -47,14 +46,19 @@ export class ListSearchIndexesTool extends MongoDBToolBase { 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) => ({ + static async getSearchIndexes( + provider: NodeDriverServiceProvider, + database: string, + collection: string + ): Promise { + const searchIndexes = await provider.getSearchIndexes(database, collection); + /** + * 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. + **/ + return searchIndexes.map((index) => ({ name: (index["name"] ?? "default") as string, type: (index["type"] ?? "UNKNOWN") as "search" | "vectorSearch", status: (index["status"] ?? "UNKNOWN") as string, diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts index 82e76075..d5df1182 100644 --- a/tests/accuracy/dropIndex.test.ts +++ b/tests/accuracy/dropIndex.test.ts @@ -1,79 +1,134 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; +import { formatUntrustedData } from "../../src/tools/tool.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", - }, - ], + content: formatUntrustedData( + "Successfully dropped the index from the provided namespace.", + JSON.stringify({ + indexName, + namespace: `${database as string}.${collection as string}`, + }) + ), }; }, } 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", +describeAccuracyTests( + [ + { + prompt: "Delete the index called year_1 from mflix.movies namespace", + expectedToolCalls: [ + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: "year_1", + type: "classic", + }, }, - }, - ], - mockedTools, - }, - { - prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace", - expectedToolCalls: [ - { - toolName: "create-index", - parameters: { - database: "mflix", - collection: "movies", - name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - definition: [ - { - keys: { - title: "text", + ], + mockedTools, + }, + { + prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + keys: { + title: "text", + }, + type: "classic", }, - type: "classic", - }, - ], + ], + }, }, - }, - { - toolName: "collection-indexes", - parameters: { - database: "mflix", - collection: "movies", + { + 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(), + type: "classic", + }, }, - }, - { - toolName: "drop-index", - parameters: { - database: "mflix", - collection: "movies", - indexName: Matcher.string(), + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + type: "classic", + }, }, - }, - ], - mockedTools, - }, -]); + ], + mockedTools, + }, + { + prompt: "Create a vector search index on 'mflix.movies' namespace on the 'plotSummary' field. The index should use 1024 dimensions. Confirm that its created and then drop the index.", + 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, + }, + ], + }, + ], + }, + }, + { + toolName: "collection-indexes", + parameters: { + database: "mflix", + collection: "movies", + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + type: "search", + }, + }, + ], + mockedTools, + }, + ], + { + userConfig: { + voyageApiKey: "voyage-api-key", + }, + clusterConfig: { search: true }, + } +); diff --git a/tests/accuracy/dropIndex.vectorSearchDisabled.test.ts b/tests/accuracy/dropIndex.vectorSearchDisabled.test.ts new file mode 100644 index 00000000..eca25090 --- /dev/null +++ b/tests/accuracy/dropIndex.vectorSearchDisabled.test.ts @@ -0,0 +1,96 @@ +/** + * Accuracy tests for when the vector search feature flag is disabled. + * + * TODO: Remove this file once we permanently enable the vector search feature. + */ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import { Matcher } from "./sdk/matcher.js"; +import { formatUntrustedData } from "../../src/tools/tool.js"; + +// We don't want to delete actual indexes +const mockedTools = { + "drop-index": ({ indexName, database, collection }: Record): CallToolResult => { + return { + content: formatUntrustedData( + "Successfully dropped the index from the provided namespace.", + JSON.stringify({ + indexName, + namespace: `${database as string}.${collection as string}`, + }) + ), + }; + }, +} 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", + type: Matcher.anyOf(Matcher.undefined, Matcher.value("classic")), + }, + }, + ], + mockedTools, + }, + { + prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + keys: { + title: "text", + }, + type: "classic", + }, + ], + }, + }, + { + toolName: "collection-indexes", + parameters: { + database: "mflix", + collection: "movies", + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + type: Matcher.anyOf(Matcher.undefined, Matcher.value("classic")), + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + type: Matcher.anyOf(Matcher.undefined, Matcher.value("classic")), + }, + }, + ], + mockedTools, + }, + ], + { + userConfig: { + voyageApiKey: "", + }, + } +); diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index ae41869e..161a8fb1 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -8,9 +8,8 @@ import { expectDefined, defaultTestConfig, } from "../../../helpers.js"; -import { ObjectId, type IndexDirection } from "mongodb"; -import { beforeEach, describe, expect, it } from "vitest"; -import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; +import { ObjectId, type Collection, type Document, type IndexDirection } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; describeWithMongoDB("createIndex tool when search is not enabled", (integration) => { it("doesn't allow creating vector search indexes", async () => { @@ -316,9 +315,7 @@ describeWithMongoDB( 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); + await integration.mongoClient().db(integration.randomDbName()).createCollection(collection); const response = await integration.mcpClient().callTool({ name: "create-index", @@ -403,12 +400,9 @@ describeWithMongoDB( describeWithMongoDB( "createIndex tool with vector search indexes", (integration) => { - let provider: NodeDriverServiceProvider; - - beforeEach(async ({ signal }) => { + beforeEach(async () => { await integration.connectMcpClient(); - provider = integration.mcpServer().session.serviceProvider; - await waitUntilSearchIsReady(provider, signal); + await waitUntilSearchIsReady(integration.mongoClient()); }); describe("when the collection does not exist", () => { @@ -457,14 +451,26 @@ describeWithMongoDB( }); describe("when the collection exists", () => { + let collectionName: string; + let collection: Collection; + beforeEach(async () => { + collectionName = new ObjectId().toString(); + collection = await integration + .mongoClient() + .db(integration.randomDbName()) + .createCollection(collectionName); + }); + + afterEach(async () => { + await collection.drop(); + }); + 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, + collection: collectionName, name: "vector_1_vector", definition: [ { @@ -480,10 +486,10 @@ describeWithMongoDB( 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.` + `Created the index "vector_1_vector" on collection "${collectionName}" 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); + const indexes = (await collection.listSearchIndexes().toArray()) as unknown as Document[]; expect(indexes).toHaveLength(1); expect(indexes[0]?.name).toEqual("vector_1_vector"); expect(indexes[0]?.type).toEqual("vectorSearch"); diff --git a/tests/integration/tools/mongodb/create/insertMany.test.ts b/tests/integration/tools/mongodb/create/insertMany.test.ts index 54baa886..e7bbd096 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -1,7 +1,7 @@ import { - createVectorSearchIndexAndWait, describeWithMongoDB, validateAutoConnectBehavior, + createVectorSearchIndexAndWait, waitUntilSearchIsReady, } from "../mongodbHelpers.js"; @@ -14,8 +14,8 @@ import { getDataFromUntrustedContent, } from "../../../helpers.js"; import { beforeEach, afterEach, expect, it } from "vitest"; -import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { ObjectId } from "bson"; +import type { Collection } from "mongodb"; describeWithMongoDB("insertMany tool when search is disabled", (integration) => { validateToolMetadata(integration, "insert-many", "Insert an array of documents into a MongoDB collection", [ @@ -109,35 +109,28 @@ describeWithMongoDB("insertMany tool when search is disabled", (integration) => describeWithMongoDB( "insertMany tool when search is enabled", (integration) => { - let provider: NodeDriverServiceProvider; + let collection: Collection; - beforeEach(async ({ signal }) => { + beforeEach(async () => { await integration.connectMcpClient(); - provider = integration.mcpServer().session.serviceProvider; - await provider.createCollection(integration.randomDbName(), "test"); - await waitUntilSearchIsReady(provider, signal); + collection = await integration.mongoClient().db(integration.randomDbName()).createCollection("test"); + await waitUntilSearchIsReady(integration.mongoClient()); }); afterEach(async () => { - await provider.dropCollection(integration.randomDbName(), "test"); + await collection.drop(); }); - it("inserts a document when the embedding is correct", async ({ signal }) => { - await createVectorSearchIndexAndWait( - provider, - integration.randomDbName(), - "test", - [ - { - type: "vector", - path: "embedding", - numDimensions: 8, - similarity: "euclidean", - quantization: "scalar", - }, - ], - signal - ); + it("inserts a document when the embedding is correct", async () => { + await createVectorSearchIndexAndWait(integration.mongoClient(), integration.randomDbName(), "test", [ + { + type: "vector", + path: "embedding", + numDimensions: 8, + similarity: "euclidean", + quantization: "scalar", + }, + ]); const response = await integration.mcpClient().callTool({ name: "insert-many", @@ -152,26 +145,20 @@ describeWithMongoDB( const insertedIds = extractInsertedIds(content); expect(insertedIds).toHaveLength(1); - const docCount = await provider.countDocuments(integration.randomDbName(), "test", { _id: insertedIds[0] }); + const docCount = await collection.countDocuments({ _id: insertedIds[0] }); expect(docCount).toBe(1); }); - it("returns an error when there is a search index and quantisation is wrong", async ({ signal }) => { - await createVectorSearchIndexAndWait( - provider, - integration.randomDbName(), - "test", - [ - { - type: "vector", - path: "embedding", - numDimensions: 8, - similarity: "euclidean", - quantization: "scalar", - }, - ], - signal - ); + it("returns an error when there is a search index and quantisation is wrong", async () => { + await createVectorSearchIndexAndWait(integration.mongoClient(), integration.randomDbName(), "test", [ + { + type: "vector", + path: "embedding", + numDimensions: 8, + similarity: "euclidean", + quantization: "scalar", + }, + ]); const response = await integration.mcpClient().callTool({ name: "insert-many", @@ -189,7 +176,7 @@ describeWithMongoDB( "- Field embedding is an embedding with 8 dimensions and scalar quantization, and the provided value is not compatible. Actual dimensions: unknown, actual quantization: unknown. Error: not-a-vector" ); - const oopsieCount = await provider.countDocuments(integration.randomDbName(), "test", { + const oopsieCount = await collection.countDocuments({ embedding: "oopsie", }); expect(oopsieCount).toBe(0); diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts index 46360b81..e18f260c 100644 --- a/tests/integration/tools/mongodb/delete/dropIndex.test.ts +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -1,18 +1,27 @@ -import { describe, beforeEach, it, afterEach, expect } from "vitest"; +import { describe, beforeEach, it, afterEach, expect, vi, type MockInstance } from "vitest"; import type { Collection } from "mongodb"; import { databaseCollectionInvalidArgs, databaseCollectionParameters, + defaultTestConfig, getDataFromUntrustedContent, getResponseContent, validateThrowsForInvalidArguments, validateToolMetadata, } from "../../../helpers.js"; -import { describeWithMongoDB } from "../mongodbHelpers.js"; +import { + describeWithMongoDB, + waitUntilSearchIndexIsListed, + waitUntilSearchIsReady, + type MongoDBIntegrationTestCase, +} from "../mongodbHelpers.js"; import { createMockElicitInput } from "../../../../utils/elicitationMocks.js"; import { Elicitation } from "../../../../../src/elicitation.js"; -describeWithMongoDB("drop-index tool", (integration) => { +function setupForClassicIndexes(integration: MongoDBIntegrationTestCase): { + getMoviesCollection: () => Collection; + getIndexName: () => string; +} { let moviesCollection: Collection; let indexName: string; beforeEach(async () => { @@ -36,144 +45,451 @@ describeWithMongoDB("drop-index tool", (integration) => { 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]`); + return { + getMoviesCollection: () => moviesCollection, + getIndexName: () => indexName, + }; +} + +function setupForVectorSearchIndexes(integration: MongoDBIntegrationTestCase): { + getMoviesCollection: () => Collection; + getIndexName: () => string; +} { + let moviesCollection: Collection; + const indexName = "searchIdx"; + beforeEach(async () => { + await integration.connectMcpClient(); + const mongoClient = integration.mongoClient(); + moviesCollection = mongoClient.db("mflix").collection("movies"); + await moviesCollection.insertMany([ + { + name: "Movie1", + plot: "This is a horrible movie about a database called BongoDB and how it tried to copy the OG MangoDB.", + }, + ]); + await waitUntilSearchIsReady(mongoClient); + await moviesCollection.createSearchIndex({ + name: indexName, + definition: { mappings: { dynamic: true } }, }); + await waitUntilSearchIndexIsListed(moviesCollection, indexName); }); - 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" }); - }); + afterEach(async () => { + // dropping collection also drops the associated search indexes + await moviesCollection.drop(); }); -}); - -const mockElicitInput = createMockElicitInput(); - -describeWithMongoDB( - "drop-index tool - when invoked via an elicitation enabled client", - (integration) => { - let moviesCollection: Collection; - let indexName: string; - - beforeEach(async () => { - moviesCollection = integration.mongoClient().db("mflix").collection("movies"); - await moviesCollection.insertMany([ - { name: "Movie1", year: 1994 }, - { name: "Movie2", year: 2001 }, - ]); - indexName = await moviesCollection.createIndex({ year: 1 }); - await integration.mcpClient().callTool({ - name: "connect", - arguments: { - connectionString: integration.connectionString(), + + return { + getMoviesCollection: () => moviesCollection, + getIndexName: () => indexName, + }; +} + +describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( + "drop-index tool", + ({ vectorSearchEnabled }) => { + describe(`when vector search feature flag is ${vectorSearchEnabled ? "enabled" : "disabled"}`, () => { + describeWithMongoDB( + "tool metadata and parameters", + (integration) => { + 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, + }, + vectorSearchEnabled + ? { + name: "type", + type: "string", + description: + "The type of index to be deleted. Use 'classic' for standard indexes and 'search' for atlas search and vector search indexes.", + required: true, + } + : { + name: "type", + type: "string", + description: "The type of index to be deleted. Is always set to 'classic'.", + required: false, + }, + ] + ); + + const invalidArgsTestCases = vectorSearchEnabled + ? [ + ...databaseCollectionInvalidArgs, + { database: "test", collection: "testColl", indexName: null, type: "classic" }, + { database: "test", collection: "testColl", indexName: undefined, type: "classic" }, + { database: "test", collection: "testColl", indexName: [], type: "classic" }, + { database: "test", collection: "testColl", indexName: true, type: "classic" }, + { database: "test", collection: "testColl", indexName: false, type: "search" }, + { database: "test", collection: "testColl", indexName: 0, type: "search" }, + { database: "test", collection: "testColl", indexName: 12, type: "search" }, + { database: "test", collection: "testColl", indexName: "", type: "search" }, + // When feature flag is enabled anything other than search and + // classic are invalid + { database: "test", collection: "testColl", indexName: "goodIndex", type: "anything" }, + ] + : [ + ...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: "" }, + // When feature flag is disabled even "search" is an invalid + // argument + { database: "test", collection: "testColl", indexName: "", type: "search" }, + ]; + + validateThrowsForInvalidArguments(integration, "drop-index", invalidArgsTestCases); }, - }); - }); + { + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: vectorSearchEnabled ? "test-api-key" : "", + }), + } + ); - afterEach(async () => { - await moviesCollection.drop(); - }); + describeWithMongoDB( + "dropping classic indexes", + (integration) => { + const { getIndexName } = setupForClassicIndexes(integration); + 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: vectorSearchEnabled + ? { database, collection, indexName: "non-existent", type: "classic" } + : { 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}` + ); + }); + } + ); - 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); - }); + 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: vectorSearchEnabled + ? { + database: "mflix", + collection: "movies", + indexName: "non-existent", + type: "classic", + } + : { 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]` + ); + }); + }); - 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, + 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: vectorSearchEnabled + ? { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "classic", + } + : { database: "mflix", collection: "movies", indexName: getIndexName() }, + }); + 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: getIndexName(), + namespace: "mflix.movies", + }); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: vectorSearchEnabled ? "test-api-key" : "", + }), + } + ); + + const mockElicitInput = createMockElicitInput(); + describeWithMongoDB( + "dropping classic indexes through an elicitation enabled client", + (integration) => { + const { getMoviesCollection, getIndexName } = setupForClassicIndexes(integration); + afterEach(() => { + mockElicitInput.clear(); + }); + + it("should ask for confirmation before proceeding with tool call", async () => { + expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmYes(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: vectorSearchEnabled + ? { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "classic", + } + : { database: "mflix", collection: "movies", indexName: getIndexName() }, + }); + 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 index named `year_1` from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(1); + }); + + it("should not drop the index if the confirmation was not provided", async () => { + expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmNo(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: vectorSearchEnabled + ? { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "classic", + } + : { database: "mflix", collection: "movies", indexName: getIndexName() }, + }); + 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 index named `year_1` from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: vectorSearchEnabled ? "test-api-key" : "", + }), + getMockElicitationInput: () => mockElicitInput, + } + ); + + describe.skipIf(!vectorSearchEnabled)("dropping vector search indexes", () => { + describeWithMongoDB( + "when connected to MongoDB without search support", + (integration) => { + it("should fail with appropriate error when invoked", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "any", collection: "foo", indexName: "default", type: "search" }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBe(true); + expect(content).toContain( + "The connected MongoDB deployment does not support vector search indexes" + ); + }); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), + } + ); + + describeWithMongoDB( + "when connected to MongoDB with search support", + (integration) => { + const { getIndexName } = setupForVectorSearchIndexes(integration); + + describe.each([ + { + title: "an index from non-existent database", + database: "non-existent-db", + collection: "non-existent-coll", + indexName: "non-existent-index", + }, + { + title: "an index from non-existent collection", + database: "mflix", + collection: "non-existent-coll", + indexName: "non-existent-index", + }, + { + title: "a non-existent index", + database: "mflix", + collection: "movies", + indexName: "non-existent-index", + }, + ])( + "and attempting to delete $title (namespace - $database $collection)", + ({ database, collection, indexName }) => { + it("should fail with appropriate error", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database, collection, indexName, type: "search" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toContain("Index does not exist in the provided namespace."); + + const data = getDataFromUntrustedContent(content); + expect(JSON.parse(data)).toMatchObject({ + indexName, + namespace: `${database}.${collection}`, + }); + }); + } + ); + + describe("and attempting to delete an existing index", () => { + it("should succeed in deleting the index", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "search", + }, + }); + 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: getIndexName(), + namespace: "mflix.movies", + }); + }); + }); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), + downloadOptions: { search: true }, + } + ); + + const mockElicitInput = createMockElicitInput(); + describeWithMongoDB( + "when invoked via an elicitation enabled client", + (integration) => { + const { getIndexName } = setupForVectorSearchIndexes(integration); + let dropSearchIndexSpy: MockInstance; + + beforeEach(() => { + // Note: Unlike drop-index tool test, we don't test the final state of + // indexes because of possible longer wait periods for changes to + // reflect, at-times taking >30 seconds. + dropSearchIndexSpy = vi.spyOn( + integration.mcpServer().session.serviceProvider, + "dropSearchIndex" + ); + }); + + afterEach(() => { + mockElicitInput.clear(); + }); + + it("should ask for confirmation before proceeding with tool call", async () => { + mockElicitInput.confirmYes(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "search", + }, + }); + 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 search index named `searchIdx` from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + + expect(dropSearchIndexSpy).toHaveBeenCalledExactlyOnceWith( + "mflix", + "movies", + getIndexName() + ); + }); + + it("should not drop the index if the confirmation was not provided", async () => { + mockElicitInput.confirmNo(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + type: "search", + }, + }); + 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 search index named `searchIdx` from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(dropSearchIndexSpy).not.toHaveBeenCalled(); + }); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), + downloadOptions: { search: true }, + getMockElicitationInput: () => mockElicitInput, + } + ); }); - 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 c6c7a6dd..d53c97df 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -1,7 +1,7 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs/promises"; -import type { Document } from "mongodb"; +import type { Collection, Document } from "mongodb"; import { MongoClient, ObjectId } from "mongodb"; import type { IntegrationTest } from "../../helpers.js"; import { @@ -10,16 +10,17 @@ 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } 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"; +export const DEFAULT_WAIT_TIMEOUT = 1000; +export const DEFAULT_RETRY_INTERVAL = 100; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testDataDumpPath = path.join(__dirname, "..", "..", "..", "accuracy", "test-data-dumps"); @@ -77,7 +78,7 @@ export type TestSuiteConfig = { getClientCapabilities?: () => MockClientCapabilities; }; -const defaultTestSuiteConfig: TestSuiteConfig = { +export const defaultTestSuiteConfig: TestSuiteConfig = { getUserConfig: () => defaultTestConfig, getDriverOptions: () => defaultDriverOptions, downloadOptions: DEFAULT_MONGODB_PROCESS_OPTIONS, @@ -280,78 +281,100 @@ export async function getServerVersion(integration: MongoDBIntegrationTestCase): const serverStatus = await client.db("admin").admin().serverStatus(); return serverStatus.version as string; } - -const SEARCH_RETRIES = 200; -const SEARCH_WAITING_TICK = 100; +export const SEARCH_WAIT_TIMEOUT = 20_000; export async function waitUntilSearchIsReady( - provider: NodeDriverServiceProvider, - abortSignal: AbortSignal + mongoClient: MongoClient, + timeout: number = SEARCH_WAIT_TIMEOUT, + interval: number = DEFAULT_RETRY_INTERVAL +): Promise { + await vi.waitFor( + async () => { + const testCollection = mongoClient.db("tempDB").collection("tempCollection"); + await testCollection.insertOne({ field1: "yay" }); + await testCollection.createSearchIndexes([{ definition: { mappings: { dynamic: true } } }]); + await testCollection.drop(); + }, + { timeout, interval } + ); +} + +async function waitUntilSearchIndexIs( + collection: Collection, + searchIndex: string, + indexValidator: (index: { name: string; queryable: boolean }) => boolean, + timeout: number, + interval: number, + getValidationFailedMessage: (searchIndexes: Document[]) => string = () => "Search index did not pass validation" ): 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); + await vi.waitFor( + async () => { + const searchIndexes = (await collection.listSearchIndexes(searchIndex).toArray()) as { + name: string; + queryable: boolean; + }[]; + + if (!searchIndexes.some((index) => indexValidator(index))) { + throw new Error(getValidationFailedMessage(searchIndexes)); + } + }, + { + timeout, + interval, } - } + ); +} - throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); +export async function waitUntilSearchIndexIsListed( + collection: Collection, + searchIndex: string, + timeout: number = SEARCH_WAIT_TIMEOUT, + interval: number = DEFAULT_RETRY_INTERVAL +): Promise { + return waitUntilSearchIndexIs( + collection, + searchIndex, + (index) => index.name === searchIndex, + timeout, + interval, + (searchIndexes) => + `Index ${searchIndex} is not yet in the index list (${searchIndexes.map(({ name }) => String(name)).join(", ")})` + ); } export async function waitUntilSearchIndexIsQueryable( - provider: NodeDriverServiceProvider, - database: string, - collection: string, - indexName: string, - abortSignal: AbortSignal + collection: Collection, + searchIndex: string, + timeout: number = SEARCH_WAIT_TIMEOUT, + interval: number = DEFAULT_RETRY_INTERVAL ): 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(SEARCH_WAITING_TICK); + return waitUntilSearchIndexIs( + collection, + searchIndex, + (index) => index.name === searchIndex && index.queryable, + timeout, + interval, + (searchIndexes) => { + const index = searchIndexes.find((index) => index.name === searchIndex); + return `Index ${searchIndex} in ${collection.dbName}.${collection.collectionName} is not ready. Last known status - ${JSON.stringify(index)}`; } - } - - throw new Error( - `Index ${indexName} in ${database}.${collection} is not ready: -lastIndexStatus: ${JSON.stringify(lastIndexStatus)} -lastError: ${JSON.stringify(lastError)}` ); } export async function createVectorSearchIndexAndWait( - provider: NodeDriverServiceProvider, + mongoClient: MongoClient, database: string, collection: string, - fields: Document[], - abortSignal: AbortSignal + fields: Document[] ): Promise { - await provider.createSearchIndexes(database, collection, [ - { - name: "default", - type: "vectorSearch", - definition: { - fields, - }, + const coll = await mongoClient.db(database).createCollection(collection); + await coll.createSearchIndex({ + name: "default", + type: "vectorSearch", + definition: { + fields, }, - ]); + }); - await waitUntilSearchIndexIsQueryable(provider, database, collection, "default", abortSignal); + await waitUntilSearchIndexIsQueryable(coll, "default"); } diff --git a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts index 7d8b86a3..39903796 100644 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -1,3 +1,4 @@ +import type { Collection } from "mongodb"; import { describeWithMongoDB, getSingleDocFromUntrustedContent, @@ -13,12 +14,11 @@ import { databaseCollectionInvalidArgs, getDataFromUntrustedContent, } from "../../../helpers.js"; -import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; -import type { SearchIndexStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; +import type { SearchIndexWithStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; const SEARCH_TIMEOUT = 60_000; -describeWithMongoDB("list search indexes tool in local MongoDB", (integration) => { +describeWithMongoDB("list-search-indexes tool in local MongoDB", (integration) => { validateToolMetadata( integration, "list-search-indexes", @@ -35,6 +35,7 @@ describeWithMongoDB("list search indexes tool in local MongoDB", (integration) = arguments: { database: "any", collection: "foo" }, }); const content = getResponseContent(response.content); + expect(response.isError).toBe(true); expect(content).toEqual( "The connected MongoDB deployment does not support vector search indexes. Either connect to a MongoDB Atlas cluster or use the Atlas CLI to create and manage a local Atlas deployment." ); @@ -42,14 +43,14 @@ describeWithMongoDB("list search indexes tool in local MongoDB", (integration) = }); describeWithMongoDB( - "list search indexes tool in Atlas", + "list-search-indexes tool in Atlas", (integration) => { - let provider: NodeDriverServiceProvider; + let fooCollection: Collection; - beforeEach(async ({ signal }) => { + beforeEach(async () => { await integration.connectMcpClient(); - provider = integration.mcpServer().session.serviceProvider; - await waitUntilSearchIsReady(provider, signal); + fooCollection = integration.mongoClient().db("any").collection("foo"); + await waitUntilSearchIsReady(integration.mongoClient(), SEARCH_TIMEOUT); }); describe("when the collection does not exist", () => { @@ -80,8 +81,9 @@ describeWithMongoDB( describe("when there are indexes", () => { beforeEach(async () => { - await provider.insertOne("any", "foo", { field1: "yay" }); - await provider.createSearchIndexes("any", "foo", [{ definition: { mappings: { dynamic: true } } }]); + await fooCollection.insertOne({ field1: "yay" }); + await waitUntilSearchIsReady(integration.mongoClient(), SEARCH_TIMEOUT); + await fooCollection.createSearchIndexes([{ definition: { mappings: { dynamic: true } } }]); }); it("returns the list of existing indexes", { timeout: SEARCH_TIMEOUT }, async () => { @@ -90,7 +92,7 @@ describeWithMongoDB( arguments: { database: "any", collection: "foo" }, }); const content = getResponseContent(response.content); - const indexDefinition = getSingleDocFromUntrustedContent(content); + const indexDefinition = getSingleDocFromUntrustedContent(content); expect(indexDefinition?.name).toEqual("default"); expect(indexDefinition?.type).toEqual("search"); @@ -100,8 +102,8 @@ describeWithMongoDB( it( "returns the list of existing indexes and detects if they are queryable", { timeout: SEARCH_TIMEOUT }, - async ({ signal }) => { - await waitUntilSearchIndexIsQueryable(provider, "any", "foo", "default", signal); + async () => { + await waitUntilSearchIndexIsQueryable(fooCollection, "default", SEARCH_TIMEOUT); const response = await integration.mcpClient().callTool({ name: "list-search-indexes", @@ -109,7 +111,7 @@ describeWithMongoDB( }); const content = getResponseContent(response.content); - const indexDefinition = getSingleDocFromUntrustedContent(content); + const indexDefinition = getSingleDocFromUntrustedContent(content); expect(indexDefinition?.name).toEqual("default"); expect(indexDefinition?.type).toEqual("search");