From ccefe629bd70350b31a67c9aefaf1c58c202b4bf Mon Sep 17 00:00:00 2001 From: nirinchev Date: Wed, 15 Oct 2025 17:39:49 +0200 Subject: [PATCH 1/3] chore: merge list-search-indexes into collection-indexes --- .../mongodb/metadata/collectionIndexes.ts | 61 +++- src/tools/mongodb/search/listSearchIndexes.ts | 81 ----- src/tools/mongodb/tools.ts | 2 - tests/accuracy/collectionIndexes.test.ts | 24 ++ tests/accuracy/listSearchIndexes.test.ts | 28 -- .../tools/mongodb/create/createIndex.test.ts | 10 +- .../mongodb/read/collectionIndexes.test.ts | 285 +++++++++++++++++- .../mongodb/search/listSearchIndexes.test.ts | 126 -------- 8 files changed, 356 insertions(+), 261 deletions(-) delete mode 100644 src/tools/mongodb/search/listSearchIndexes.ts delete mode 100644 tests/accuracy/listSearchIndexes.test.ts delete mode 100644 tests/integration/tools/mongodb/search/listSearchIndexes.test.ts diff --git a/src/tools/mongodb/metadata/collectionIndexes.ts b/src/tools/mongodb/metadata/collectionIndexes.ts index 6da2c7886..6c03ac1c6 100644 --- a/src/tools/mongodb/metadata/collectionIndexes.ts +++ b/src/tools/mongodb/metadata/collectionIndexes.ts @@ -1,7 +1,20 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import type { ToolArgs, OperationType } from "../../tool.js"; -import { formatUntrustedData } from "../../tool.js"; +import { FeatureFlags, formatUntrustedData } from "../../tool.js"; + +type SearchIndexStatus = { + name: string; + type: string; + status: string; + queryable: boolean; + latestDefinition: Document; +}; + +type IndexStatus = { + name: string; + key: Document; +}; export class CollectionIndexesTool extends MongoDBToolBase { public name = "collection-indexes"; @@ -12,16 +25,30 @@ export class CollectionIndexesTool extends MongoDBToolBase { protected async execute({ database, collection }: ToolArgs): Promise { const provider = await this.ensureConnected(); const indexes = await provider.getIndexes(database, collection); + const indexDefinitions: IndexStatus[] = indexes.map((index) => ({ + name: index.name as string, + key: index.key as Document, + })); + + const searchIndexDefinitions: SearchIndexStatus[] = []; + if (this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) && (await this.session.isSearchSupported())) { + const searchIndexes = await provider.getSearchIndexes(database, collection); + searchIndexDefinitions.push(...this.extractSearchIndexDetails(searchIndexes)); + } return { - content: formatUntrustedData( - `Found ${indexes.length} indexes in the collection "${collection}":`, - indexes.length > 0 - ? indexes - .map((index) => `Name: "${index.name}", definition: ${JSON.stringify(index.key)}`) - .join("\n") - : undefined - ), + content: [ + ...formatUntrustedData( + `Found ${indexDefinitions.length} indexes in the collection "${collection}":`, + indexDefinitions.length > 0 ? JSON.stringify(indexDefinitions, null, 2) : undefined + ), + ...(searchIndexDefinitions.length > 0 + ? formatUntrustedData( + `Found ${searchIndexDefinitions.length} search and vector search indexes in the collection "${collection}":`, + JSON.stringify(searchIndexDefinitions, null, 2) + ) + : []), + ], }; } @@ -43,4 +70,20 @@ export class CollectionIndexesTool extends MongoDBToolBase { return super.handleError(error, args); } + + /** + * 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 extractSearchIndexDetails(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, + })); + } } diff --git a/src/tools/mongodb/search/listSearchIndexes.ts b/src/tools/mongodb/search/listSearchIndexes.ts deleted file mode 100644 index 1b520d523..000000000 --- a/src/tools/mongodb/search/listSearchIndexes.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 6e96b2ba6..c4498c805 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -19,7 +19,6 @@ import { ExplainTool } from "./metadata/explain.js"; import { CreateCollectionTool } from "./create/createCollection.js"; import { LogsTool } from "./metadata/logs.js"; import { ExportTool } from "./read/export.js"; -import { ListSearchIndexesTool } from "./search/listSearchIndexes.js"; import { DropIndexTool } from "./delete/dropIndex.js"; export const MongoDbTools = [ @@ -45,5 +44,4 @@ export const MongoDbTools = [ CreateCollectionTool, LogsTool, ExportTool, - ListSearchIndexesTool, ]; diff --git a/tests/accuracy/collectionIndexes.test.ts b/tests/accuracy/collectionIndexes.test.ts index 45ad2b7e0..73d28a70d 100644 --- a/tests/accuracy/collectionIndexes.test.ts +++ b/tests/accuracy/collectionIndexes.test.ts @@ -37,4 +37,28 @@ describeAccuracyTests([ }, ], }, + { + prompt: "how many search indexes do I have in the collection mydb.mycoll?", + expectedToolCalls: [ + { + toolName: "collection-indexes", + parameters: { + database: "mydb", + collection: "mycoll", + }, + }, + ], + }, + { + prompt: "which vector search indexes do I have in mydb.mycoll?", + expectedToolCalls: [ + { + toolName: "collection-indexes", + parameters: { + database: "mydb", + collection: "mycoll", + }, + }, + ], + }, ]); diff --git a/tests/accuracy/listSearchIndexes.test.ts b/tests/accuracy/listSearchIndexes.test.ts deleted file mode 100644 index 6f4a2d1ce..000000000 --- a/tests/accuracy/listSearchIndexes.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index ae41869ea..7862d6d95 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -499,12 +499,10 @@ describeWithMongoDB( }); }, { - getUserConfig: () => { - return { - ...defaultTestConfig, - voyageApiKey: "valid_key", - }; - }, + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: "valid_key", + }), downloadOptions: { search: true, }, diff --git a/tests/integration/tools/mongodb/read/collectionIndexes.test.ts b/tests/integration/tools/mongodb/read/collectionIndexes.test.ts index d4b4ded04..f9e9d71f2 100644 --- a/tests/integration/tools/mongodb/read/collectionIndexes.test.ts +++ b/tests/integration/tools/mongodb/read/collectionIndexes.test.ts @@ -5,9 +5,19 @@ import { validateThrowsForInvalidArguments, getResponseElements, databaseCollectionInvalidArgs, + getDataFromUntrustedContent, + getResponseContent, + defaultTestConfig, + expectDefined, } from "../../../helpers.js"; -import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; -import { expect, it } from "vitest"; +import { + describeWithMongoDB, + validateAutoConnectBehavior, + waitUntilSearchIndexIsQueryable, + waitUntilSearchIsReady, +} from "../mongodbHelpers.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; describeWithMongoDB("collectionIndexes tool", (integration) => { validateToolMetadata( @@ -48,7 +58,8 @@ describeWithMongoDB("collectionIndexes tool", (integration) => { const elements = getResponseElements(response.content); expect(elements).toHaveLength(2); expect(elements[0]?.text).toEqual('Found 1 indexes in the collection "people":'); - expect(elements[1]?.text).toContain('Name: "_id_", definition: {"_id":1}'); + const indexDefinitions = JSON.parse(getDataFromUntrustedContent(elements[1]?.text || "")) as []; + expect(indexDefinitions).toEqual([{ name: "_id_", key: { _id: 1 } }]); }); it("returns all indexes for a collection", async () => { @@ -79,17 +90,19 @@ describeWithMongoDB("collectionIndexes tool", (integration) => { expect(elements).toHaveLength(2); expect(elements[0]?.text).toEqual(`Found ${indexTypes.length + 1} indexes in the collection "people":`); - expect(elements[1]?.text).toContain('Name: "_id_", definition: {"_id":1}'); + const indexDefinitions = JSON.parse(getDataFromUntrustedContent(elements[1]?.text || "")) as []; + expect(indexDefinitions).toContainEqual({ name: "_id_", key: { _id: 1 } }); for (const indexType of indexTypes) { - let expectedDefinition = JSON.stringify({ [`prop_${indexType}`]: indexType }); + let expectedDefinition = { [`prop_${indexType}`]: indexType }; if (indexType === "text") { - expectedDefinition = '{"_fts":"text"'; + expectedDefinition = { _fts: "text", _ftsx: 1 }; } - expect(elements[1]?.text).toContain( - `Name: "${indexNames.get(indexType)}", definition: ${expectedDefinition}` - ); + expect(indexDefinitions).toContainEqual({ + name: indexNames.get(indexType), + key: expectedDefinition, + }); } }); @@ -100,3 +113,257 @@ describeWithMongoDB("collectionIndexes tool", (integration) => { }; }); }); +const SEARCH_TIMEOUT = 20_000; + +describeWithMongoDB( + "collection-indexes tool with Search", + (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: "collection-indexes", + arguments: { database: "any", collection: "foo" }, + }); + const responseContent = getResponseContent(response.content); + expect(responseContent).toContain( + 'The indexes for "any.foo" cannot be determined because the collection does not exist.' + ); + }); + }); + + describe("when there are no search indexes", () => { + beforeEach(async () => { + await provider.createIndexes(integration.randomDbName(), "foo", [{ key: { foo: 1 } }]); + }); + + it("returns an just the regular indexes", async () => { + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const responseElements = getResponseElements(response.content); + expect(responseElements).toHaveLength(2); + // Expect 2 indexes - _id_ and foo_1 + expect(responseElements[0]?.text).toContain('Found 2 indexes in the collection "foo"'); + + const responseContent = getResponseContent(response.content); + expect(responseContent).not.toContain("search and vector search indexes"); + }); + }); + + describe("when there are vector search indexes", () => { + beforeEach(async () => { + await provider.insertOne(integration.randomDbName(), "foo", { + field1: "yay", + age: 1, + field1_embeddings: [1, 2, 3, 4], + }); + await provider.createSearchIndexes(integration.randomDbName(), "foo", [ + { + name: "my-vector-index", + definition: { + fields: [ + { type: "vector", path: "field1_embeddings", numDimensions: 4, similarity: "cosine" }, + ], + }, + type: "vectorSearch", + }, + { + name: "my-mixed-index", + definition: { + fields: [ + { + type: "vector", + path: "field1_embeddings", + numDimensions: 4, + similarity: "euclidean", + }, + { type: "filter", path: "age" }, + ], + }, + type: "vectorSearch", + }, + ]); + }); + + it("returns the list of existing indexes", { timeout: SEARCH_TIMEOUT }, async () => { + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(4); + + // Expect 1 regular index - _id_ + expect(elements[0]?.text).toContain(`Found 1 indexes in the collection "foo":`); + expect(elements[2]?.text).toContain( + `Found 2 search and vector search indexes in the collection "foo":` + ); + + const indexDefinitions = JSON.parse(getDataFromUntrustedContent(elements[3]?.text || "")) as { + name: string; + type: string; + latestDefinition: { fields: unknown[] }; + }[]; + expect(indexDefinitions).toHaveLength(2); + expect(indexDefinitions[0]).toHaveProperty("name", "my-vector-index"); + expect(indexDefinitions[0]).toHaveProperty("type", "vectorSearch"); + const fields0 = indexDefinitions[0]?.latestDefinition.fields; + expectDefined(fields0); + expect(fields0).toHaveLength(1); + expect(fields0[0]).toHaveProperty("type", "vector"); + expect(fields0[0]).toHaveProperty("path", "field1_embeddings"); + + expect(indexDefinitions[1]).toHaveProperty("name", "my-mixed-index"); + expect(indexDefinitions[1]).toHaveProperty("type", "vectorSearch"); + const fields1 = indexDefinitions[1]?.latestDefinition.fields; + expectDefined(fields1); + expect(fields1).toHaveLength(2); + expect(fields1[0]).toHaveProperty("type", "vector"); + expect(fields1[0]).toHaveProperty("path", "field1_embeddings"); + expect(fields1[1]).toHaveProperty("type", "filter"); + expect(fields1[1]).toHaveProperty("path", "age"); + }); + + it( + "returns the list of existing indexes and detects if they are queryable", + { timeout: SEARCH_TIMEOUT }, + async ({ signal }) => { + await waitUntilSearchIndexIsQueryable( + provider, + integration.randomDbName(), + "foo", + "my-vector-index", + signal + ); + await waitUntilSearchIndexIsQueryable( + provider, + integration.randomDbName(), + "foo", + "my-mixed-index", + signal + ); + + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const elements = getResponseElements(response.content); + const indexDefinitions = JSON.parse(getDataFromUntrustedContent(elements[3]?.text || "")) as { + name: string; + }[]; + + const vectorIndexDefinition = indexDefinitions.find((def) => def.name === "my-vector-index"); + + expect(vectorIndexDefinition).toHaveProperty("queryable", true); + expect(vectorIndexDefinition).toHaveProperty("status", "READY"); + + const mixedIndexDefinition = indexDefinitions.find((def) => def.name === "my-mixed-index"); + expect(mixedIndexDefinition).toHaveProperty("queryable", true); + expect(mixedIndexDefinition).toHaveProperty("status", "READY"); + } + ); + }); + + describe("when there are Atlas search indexes", () => { + beforeEach(async () => { + await provider.insertOne(integration.randomDbName(), "foo", { field1: "yay", age: 1 }); + await provider.createSearchIndexes(integration.randomDbName(), "foo", [ + { name: "my-search-index", definition: { mappings: { dynamic: true } }, type: "search" }, + ]); + }); + + it("returns them alongside the regular indexes", async () => { + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(4); + // Expect 1 regular index - _id_ + expect(elements[0]?.text).toContain(`Found 1 indexes in the collection "foo":`); + expect(elements[2]?.text).toContain( + `Found 1 search and vector search indexes in the collection "foo":` + ); + + const indexDefinitions = JSON.parse(getDataFromUntrustedContent(elements[3]?.text || "")) as { + name: string; + type: string; + latestDefinition: unknown; + }[]; + + expect(indexDefinitions).toHaveLength(1); + expect(indexDefinitions[0]).toHaveProperty("name", "my-search-index"); + expect(indexDefinitions[0]).toHaveProperty("type", "search"); + expect(indexDefinitions[0]).toHaveProperty("latestDefinition", { + mappings: { dynamic: true, fields: {} }, + }); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: "valid_key", + }), + downloadOptions: { search: true }, + } +); + +describeWithMongoDB( + "collectionIndexes tool without voyage API key", + (integration) => { + let provider: NodeDriverServiceProvider; + + beforeEach(async ({ signal }) => { + await integration.connectMcpClient(); + provider = integration.mcpServer().session.serviceProvider; + await waitUntilSearchIsReady(provider, signal); + + await provider.insertOne(integration.randomDbName(), "foo", { field1: "yay", age: 1 }); + await provider.createSearchIndexes(integration.randomDbName(), "foo", [ + { + name: "my-vector-index", + definition: { + fields: [{ type: "vector", path: "field1_embeddings", numDimensions: 4, similarity: "cosine" }], + }, + type: "vectorSearch", + }, + ]); + }); + it("does not return search indexes", async () => { + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + // Expect 1 regular index - _id_ + expect(elements[0]?.text).toContain(`Found 1 indexes in the collection "foo"`); + + const responseContent = getResponseContent(response.content); + expect(responseContent).not.toContain("search and vector search indexes"); + + // Ensure that we do have search indexes + const searchIndexes = await provider.getSearchIndexes(integration.randomDbName(), "foo"); + expect(searchIndexes).toHaveLength(1); + expect(searchIndexes[0]).toHaveProperty("name", "my-vector-index"); + }); + }, + { + downloadOptions: { search: true }, + } +); diff --git a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts deleted file mode 100644 index 477f9faee..000000000 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - describeWithMongoDB, - getSingleDocFromUntrustedContent, - waitUntilSearchIndexIsQueryable, - waitUntilSearchIsReady, -} from "../mongodbHelpers.js"; -import { describe, it, expect, beforeEach } from "vitest"; -import { - getResponseContent, - databaseCollectionParameters, - validateToolMetadata, - validateThrowsForInvalidArguments, - databaseCollectionInvalidArgs, - getDataFromUntrustedContent, -} from "../../../helpers.js"; -import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; -import type { SearchIndexStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; - -const SEARCH_TIMEOUT = 20_000; - -describeWithMongoDB("list search indexes tool in local MongoDB", (integration) => { - validateToolMetadata( - integration, - "list-search-indexes", - "Describes the search and vector search indexes for a single collection", - databaseCollectionParameters - ); - - validateThrowsForInvalidArguments(integration, "list-search-indexes", databaseCollectionInvalidArgs); - - it("fails for clusters without MongoDB Search", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "list-search-indexes", - arguments: { database: "any", collection: "foo" }, - }); - const content = getResponseContent(response.content); - expect(content).toEqual( - "This MongoDB cluster does not support Search Indexes. Make sure you are using an Atlas Cluster, either remotely in Atlas or using the Atlas Local image, or your cluster supports MongoDB Search." - ); - }); -}); - -describeWithMongoDB( - "list search indexes tool in Atlas", - (integration) => { - let provider: NodeDriverServiceProvider; - - beforeEach(async ({ signal }) => { - await integration.connectMcpClient(); - provider = integration.mcpServer().session.serviceProvider; - await waitUntilSearchIsReady(provider, signal); - }); - - describe("when the collection does not exist", () => { - it("returns an empty list of indexes", async () => { - const response = await integration.mcpClient().callTool({ - name: "list-search-indexes", - arguments: { database: "any", collection: "foo" }, - }); - const responseContent = getResponseContent(response.content); - const content = getDataFromUntrustedContent(responseContent); - expect(responseContent).toContain("Could not retrieve search indexes"); - expect(content).toEqual("There are no search or vector search indexes in any.foo"); - }); - }); - - describe("when there are no indexes", () => { - it("returns an empty list of indexes", async () => { - const response = await integration.mcpClient().callTool({ - name: "list-search-indexes", - arguments: { database: "any", collection: "foo" }, - }); - const responseContent = getResponseContent(response.content); - const content = getDataFromUntrustedContent(responseContent); - expect(responseContent).toContain("Could not retrieve search indexes"); - expect(content).toEqual("There are no search or vector search indexes in any.foo"); - }); - }); - - describe("when there are indexes", () => { - beforeEach(async () => { - await provider.insertOne("any", "foo", { field1: "yay" }); - await provider.createSearchIndexes("any", "foo", [{ definition: { mappings: { dynamic: true } } }]); - }); - - it("returns the list of existing indexes", { timeout: SEARCH_TIMEOUT }, async () => { - const response = await integration.mcpClient().callTool({ - name: "list-search-indexes", - arguments: { database: "any", collection: "foo" }, - }); - const content = getResponseContent(response.content); - const indexDefinition = getSingleDocFromUntrustedContent(content); - - expect(indexDefinition?.name).toEqual("default"); - expect(indexDefinition?.type).toEqual("search"); - expect(indexDefinition?.latestDefinition).toEqual({ mappings: { dynamic: true, fields: {} } }); - }); - - it( - "returns the list of existing indexes and detects if they are queryable", - { timeout: SEARCH_TIMEOUT }, - async ({ signal }) => { - await waitUntilSearchIndexIsQueryable(provider, "any", "foo", "default", signal); - - const response = await integration.mcpClient().callTool({ - name: "list-search-indexes", - arguments: { database: "any", collection: "foo" }, - }); - - const content = getResponseContent(response.content); - const indexDefinition = getSingleDocFromUntrustedContent(content); - - expect(indexDefinition?.name).toEqual("default"); - expect(indexDefinition?.type).toEqual("search"); - expect(indexDefinition?.latestDefinition).toEqual({ mappings: { dynamic: true, fields: {} } }); - expect(indexDefinition?.queryable).toEqual(true); - expect(indexDefinition?.status).toEqual("READY"); - } - ); - }); - }, - { - downloadOptions: { search: true }, - } -); From 56a50b7b27c4a5097ffea0e5487550a0333de967 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Mon, 20 Oct 2025 10:03:59 +0300 Subject: [PATCH 2/3] remove an unnecessary an --- .../tools/mongodb/metadata/collectionIndexes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts b/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts index b6e795b65..868d8d0a1 100644 --- a/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts +++ b/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts @@ -149,7 +149,7 @@ describeWithMongoDB( await collection.createIndexes([{ key: { foo: 1 } }]); }); - it("returns an just the regular indexes", async () => { + it("returns just the regular indexes", async () => { const response = await integration.mcpClient().callTool({ name: "collection-indexes", arguments: { database: integration.randomDbName(), collection: "foo" }, From 3315b049916ce453d45b55f72a182285a67a46c2 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Mon, 20 Oct 2025 10:52:15 +0300 Subject: [PATCH 3/3] chore: simplify drop-index API --- src/tools/mongodb/delete/dropIndex.ts | 101 ++- tests/accuracy/dropIndex.test.ts | 5 - .../tools/mongodb/delete/dropIndex.test.ts | 640 +++++++----------- 3 files changed, 293 insertions(+), 453 deletions(-) diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts index dea72bf83..c911adb09 100644 --- a/src/tools/mongodb/delete/dropIndex.ts +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -1,6 +1,5 @@ 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, FeatureFlags } from "../../tool.js"; @@ -10,86 +9,62 @@ 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(toolArgs: ToolArgs): Promise { + protected async execute({ + database, + collection, + indexName, + }: ToolArgs): Promise { const provider = await this.ensureConnected(); - switch (toolArgs.type) { - case "classic": - return this.dropClassicIndex(provider, toolArgs); - case "search": - return this.dropSearchIndex(provider, toolArgs); - } - } + const existingIndex = (await provider.getIndexes(database, collection)).find((idx) => idx.name === indexName); + if (existingIndex) { + const result = await provider.runCommand(database, { + dropIndexes: collection, + index: indexName, + }); - private async dropClassicIndex( - provider: NodeDriverServiceProvider, - { database, collection, indexName }: ToolArgs - ): Promise { - 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, - }; - } - - private async dropSearchIndex( - provider: NodeDriverServiceProvider, - { database, collection, indexName }: ToolArgs - ): Promise { - await this.ensureSearchIsSupported(); - const indexes = await provider.getSearchIndexes(database, collection, indexName); - if (indexes.length === 0) { return { content: formatUntrustedData( - "Index does not exist in the provided namespace.", - JSON.stringify({ indexName, namespace: `${database}.${collection}` }) + `${result.ok ? "Successfully dropped" : "Failed to drop"} the index from the provided namespace.`, + JSON.stringify({ + indexName, + namespace: `${database}.${collection}`, + }) ), - isError: true, + isError: result.ok ? undefined : true, }; } - await provider.dropSearchIndex(database, collection, indexName); + if (this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) && (await this.session.isSearchSupported())) { + const existingSearchIndex = (await provider.getSearchIndexes(database, collection, indexName))[0]; + if (existingSearchIndex) { + await provider.dropSearchIndex(database, collection, indexName); + return { + content: formatUntrustedData( + "Successfully dropped the index from the provided namespace.", + JSON.stringify({ + indexName, + namespace: `${database}.${collection}`, + }) + ), + }; + } + } + return { content: formatUntrustedData( - "Successfully dropped the index from the provided namespace.", - JSON.stringify({ - indexName, - namespace: `${database}.${collection}`, - }) + "Index does not exist in the provided namespace.", + JSON.stringify({ indexName, namespace: `${database}.${collection}` }) ), + isError: true, }; } - protected getConfirmationMessage({ - database, - collection, - indexName, - type, - }: ToolArgs): string { + protected getConfirmationMessage({ database, collection, indexName }: ToolArgs): string { return ( - `You are about to drop the ${type === "search" ? "search index" : "index"} named \`${indexName}\` from the \`${database}.${collection}\` namespace:\n\n` + + `You are about to drop the 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/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts index d5df1182b..b1f7250ca 100644 --- a/tests/accuracy/dropIndex.test.ts +++ b/tests/accuracy/dropIndex.test.ts @@ -29,7 +29,6 @@ describeAccuracyTests( database: "mflix", collection: "movies", indexName: "year_1", - type: "classic", }, }, ], @@ -49,7 +48,6 @@ describeAccuracyTests( keys: { title: "text", }, - type: "classic", }, ], }, @@ -67,7 +65,6 @@ describeAccuracyTests( database: "mflix", collection: "movies", indexName: Matcher.string(), - type: "classic", }, }, { @@ -76,7 +73,6 @@ describeAccuracyTests( database: "mflix", collection: "movies", indexName: Matcher.string(), - type: "classic", }, }, ], @@ -118,7 +114,6 @@ describeAccuracyTests( database: "mflix", collection: "movies", indexName: Matcher.string(), - type: "search", }, }, ], diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts index e18f260cf..c6589d286 100644 --- a/tests/integration/tools/mongodb/delete/dropIndex.test.ts +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -2,12 +2,10 @@ import { describe, beforeEach, it, afterEach, expect, vi, type MockInstance } fr import type { Collection } from "mongodb"; import { databaseCollectionInvalidArgs, - databaseCollectionParameters, defaultTestConfig, getDataFromUntrustedContent, getResponseContent, validateThrowsForInvalidArguments, - validateToolMetadata, } from "../../../helpers.js"; import { describeWithMongoDB, @@ -86,410 +84,282 @@ function setupForVectorSearchIndexes(integration: MongoDBIntegrationTestCase): { }; } -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, - }, - ] - ); +describe("drop-index tool with classic indexes", () => { + describeWithMongoDB("tool metadata and parameters", (integration) => { + const invalidArgsTestCases = [ + ...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: "" }, + ]; - 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); + }); - validateThrowsForInvalidArguments(integration, "drop-index", invalidArgsTestCases); - }, - { - getUserConfig: () => ({ - ...defaultTestConfig, - voyageApiKey: vectorSearchEnabled ? "test-api-key" : "", - }), - } - ); + 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: { database, collection, indexName: "non-existent" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual(`Error running drop-index: ns does not exist: ${database}.${collection}`); + }); + } + ); - 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}` - ); - }); - } - ); + 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).toContain("Index does not exist in the provided namespace"); + expect(content).toContain("non-existent"); + expect(content).toContain("mflix.movies"); + }); + }); - 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]` - ); - }); - }); + 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: 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", + }); + }); + }); + }); - 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(); + }); - 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: { 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 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: { 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); + }); + }, + { + getMockElicitationInput: () => mockElicitInput, + } + ); +}); - 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({ +describe("drop-index tool with search indexes", () => { + describeWithMongoDB( + "dropping search indexes", + (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", + }, + ])( + "when attempting to delete an index from non-existent namespace - $database $collection", + ({ database, collection, indexName }) => { + it("should fail with error", async () => { + const response = await integration.mcpClient().callTool({ name: "drop-index", - arguments: vectorSearchEnabled - ? { - database: "mflix", - collection: "movies", - indexName: getIndexName(), - type: "classic", - } - : { database: "mflix", collection: "movies", indexName: getIndexName() }, + arguments: { database, collection, 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 index named `year_1` from the `mflix.movies` namespace" - ), - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); - expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Error running drop-index: ns does not exist: ${database}.${collection}` + ); }); - }, - { - getUserConfig: () => ({ - ...defaultTestConfig, - voyageApiKey: vectorSearchEnabled ? "test-api-key" : "", - }), - getMockElicitationInput: () => mockElicitInput, } ); + 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).toContain("Index does not exist in the provided namespace"); + expect(content).toContain("non-existent"); + expect(content).toContain("mflix.movies"); + }); + }); - 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" }), - } - ); + describe("when attempting to delete an index that exists", () => { + it("should succeed", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: "mflix", + collection: "movies", + indexName: getIndexName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully dropped the index from the provided namespace."); - describeWithMongoDB( - "when connected to MongoDB with search support", - (integration) => { - const { getIndexName } = setupForVectorSearchIndexes(integration); + const data = getDataFromUntrustedContent(content); + expect(JSON.parse(data)).toMatchObject({ + indexName: getIndexName(), + namespace: "mflix.movies", + }); + }); + }); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), + downloadOptions: { search: true }, + } + ); - 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 mockElicitInput = createMockElicitInput(); + describeWithMongoDB( + "dropping search indexes through an elicitation enabled client", + (integration) => { + const { getIndexName } = setupForVectorSearchIndexes(integration); + let dropSearchIndexSpy: MockInstance; - const data = getDataFromUntrustedContent(content); - expect(JSON.parse(data)).toMatchObject({ - indexName, - namespace: `${database}.${collection}`, - }); - }); - } - ); + 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"); + }); - 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." - ); + afterEach(() => { + mockElicitInput.clear(); + }); - const data = getDataFromUntrustedContent(content); - expect(JSON.parse(data)).toMatchObject({ - indexName: getIndexName(), - namespace: "mflix.movies", - }); - }); - }); + 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(), }, - { - getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), - downloadOptions: { search: true }, - } - ); + }); + 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 \`${getIndexName()}\` from the \`mflix.movies\` namespace` + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); - 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() - ); - }); + 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(); - }); + 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(), }, - { - getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), - downloadOptions: { search: true }, - getMockElicitationInput: () => mockElicitInput, - } - ); + }); + 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 \`${getIndexName()}\` from the \`mflix.movies\` namespace` + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(dropSearchIndexSpy).not.toHaveBeenCalled(); }); - }); - } -); + }, + { + getUserConfig: () => ({ ...defaultTestConfig, voyageApiKey: "test-api-key" }), + downloadOptions: { search: true }, + getMockElicitationInput: () => mockElicitInput, + } + ); +});