diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts index e69d92dc8..c911adb09 100644 --- a/src/tools/mongodb/delete/dropIndex.ts +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -1,9 +1,7 @@ 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"; -import { ListSearchIndexesTool } from "../search/listSearchIndexes.js"; export class DropIndexTool extends MongoDBToolBase { public name = "drop-index"; @@ -11,87 +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 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}` }) + `${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/src/tools/mongodb/metadata/collectionIndexes.ts b/src/tools/mongodb/metadata/collectionIndexes.ts index f765bf90a..a04596b9b 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,12 +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.map((index) => `Name: "${index.name}", definition: ${JSON.stringify(index.key)}`) - ), + content: [ + ...formatUntrustedData( + `Found ${indexDefinitions.length} indexes in the collection "${collection}":`, + ...indexDefinitions.map((i) => JSON.stringify(i)) + ), + ...(searchIndexDefinitions.length > 0 + ? formatUntrustedData( + `Found ${searchIndexDefinitions.length} search and vector search indexes in the collection "${collection}":`, + ...searchIndexDefinitions.map((i) => JSON.stringify(i)) + ) + : []), + ], }; } @@ -39,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 39af76854..000000000 --- a/src/tools/mongodb/search/listSearchIndexes.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 SearchIndexWithStatus = { - name: string; - type: "search" | "vectorSearch"; - 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(); - await this.ensureSearchIsSupported(); - const searchIndexes = await ListSearchIndexesTool.getSearchIndexes(provider, database, collection); - - if (searchIndexes.length > 0) { - return { - content: formatUntrustedData( - `Found ${searchIndexes.length} search and vector search indexes in ${database}.${collection}`, - ...searchIndexes.map((index) => EJSON.stringify(index)) - ), - }; - } 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"; - } - - 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, - queryable: (index["queryable"] ?? false) as boolean, - latestDefinition: index["latestDefinition"] as Document, - })); - } -} 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/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/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 161a8fb17..43568ece3 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -505,12 +505,10 @@ describeWithMongoDB( }); }, { - getUserConfig: () => { - return { - ...defaultTestConfig, - voyageApiKey: "valid_key", - }; - }, + getUserConfig: () => ({ + ...defaultTestConfig, + voyageApiKey: "valid_key", + }), downloadOptions: { search: true, }, diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts 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, + } + ); +}); diff --git a/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts b/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts new file mode 100644 index 000000000..868d8d0a1 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/collectionIndexes.test.ts @@ -0,0 +1,368 @@ +import type { Collection, IndexDirection } from "mongodb"; +import { + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, + databaseCollectionInvalidArgs, + getDataFromUntrustedContent, + getResponseContent, + defaultTestConfig, + expectDefined, +} from "../../../helpers.js"; +import { + describeWithMongoDB, + validateAutoConnectBehavior, + waitUntilSearchIndexIsQueryable, + waitUntilSearchIsReady, +} from "../mongodbHelpers.js"; +import { beforeEach, describe, expect, it } from "vitest"; + +const getIndexesFromContent = (content?: string): Array => { + const data = getDataFromUntrustedContent(content || ""); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return data.split("\n").map((line) => JSON.parse(line)); +}; + +describeWithMongoDB("collectionIndexes tool", (integration) => { + validateToolMetadata( + integration, + "collection-indexes", + "Describe the indexes for a collection", + databaseCollectionParameters + ); + + validateThrowsForInvalidArguments(integration, "collection-indexes", databaseCollectionInvalidArgs); + + it("can inspect indexes on non-existent database", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: "non-existent", collection: "people" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(1); + expect(elements[0]?.text).toEqual( + 'The indexes for "non-existent.people" cannot be determined because the collection does not exist.' + ); + }); + + it("returns the _id index for a new collection", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { + database: integration.randomDbName(), + collection: "people", + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + expect(elements[0]?.text).toEqual('Found 1 indexes in the collection "people":'); + const indexDefinitions = getIndexesFromContent(elements[1]?.text); + expect(indexDefinitions).toEqual([{ name: "_id_", key: { _id: 1 } }]); + }); + + it("returns all indexes for a collection", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); + + const indexTypes: IndexDirection[] = [-1, 1, "2d", "2dsphere", "text", "hashed"]; + const indexNames: Map = new Map(); + for (const indexType of indexTypes) { + const indexName = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("people") + .createIndex({ [`prop_${indexType}`]: indexType }); + + indexNames.set(indexType, indexName); + } + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { + database: integration.randomDbName(), + collection: "people", + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toEqual(`Found ${indexTypes.length + 1} indexes in the collection "people":`); + const indexDefinitions = getIndexesFromContent(elements[1]?.text); + expect(indexDefinitions).toContainEqual({ name: "_id_", key: { _id: 1 } }); + + for (const indexType of indexTypes) { + let expectedDefinition = { [`prop_${indexType}`]: indexType }; + if (indexType === "text") { + expectedDefinition = { _fts: "text", _ftsx: 1 }; + } + + expect(indexDefinitions).toContainEqual({ + name: indexNames.get(indexType), + key: expectedDefinition, + }); + } + }); + + validateAutoConnectBehavior(integration, "collection-indexes", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1" }, + expectedResponse: `The indexes for "${integration.randomDbName()}.coll1" cannot be determined because the collection does not exist.`, + }; + }); +}); +const SEARCH_TIMEOUT = 20_000; + +describeWithMongoDB( + "collection-indexes tool with Search", + (integration) => { + let collection: Collection; + + beforeEach(async () => { + await integration.connectMcpClient(); + collection = integration.mongoClient().db(integration.randomDbName()).collection("foo"); + await waitUntilSearchIsReady(integration.mongoClient()); + }); + + 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 collection.createIndexes([{ key: { foo: 1 } }]); + }); + + it("returns 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 collection.insertOne({ + field1: "yay", + age: 1, + field1_embeddings: [1, 2, 3, 4], + }); + await collection.createSearchIndexes([ + { + 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 = getIndexesFromContent(elements[3]?.text) as { + name: string; + type: string; + latestDefinition: { fields: unknown[] }; + }[]; + + expect(indexDefinitions).toHaveLength(2); + + const vectorIndexDefinition = indexDefinitions.find((def) => def.name === "my-vector-index"); + expectDefined(vectorIndexDefinition); + expect(vectorIndexDefinition).toHaveProperty("name", "my-vector-index"); + expect(vectorIndexDefinition).toHaveProperty("type", "vectorSearch"); + + const fields0 = vectorIndexDefinition.latestDefinition.fields; + expect(fields0).toHaveLength(1); + expect(fields0[0]).toHaveProperty("type", "vector"); + expect(fields0[0]).toHaveProperty("path", "field1_embeddings"); + + const mixedIndexDefinition = indexDefinitions.find((def) => def.name === "my-mixed-index"); + expectDefined(mixedIndexDefinition); + expect(mixedIndexDefinition).toHaveProperty("name", "my-mixed-index"); + expect(mixedIndexDefinition).toHaveProperty("type", "vectorSearch"); + const fields1 = mixedIndexDefinition.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 () => { + await waitUntilSearchIndexIsQueryable(collection, "my-vector-index"); + await waitUntilSearchIndexIsQueryable(collection, "my-mixed-index"); + + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + + const elements = getResponseElements(response.content); + const indexDefinitions = getIndexesFromContent(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 collection.insertOne({ field1: "yay", age: 1 }); + await collection.createSearchIndexes([ + { 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 = getIndexesFromContent(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 collection: Collection; + + beforeEach(async () => { + await integration.connectMcpClient(); + collection = integration.mongoClient().db(integration.randomDbName()).collection("foo"); + await waitUntilSearchIsReady(integration.mongoClient()); + + await collection.insertOne({ field1: "yay", age: 1 }); + await collection.createSearchIndexes([ + { + 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 collection.listSearchIndexes().toArray(); + expect(searchIndexes).toHaveLength(1); + expect(searchIndexes[0]).toHaveProperty("name", "my-vector-index"); + }); + }, + { + downloadOptions: { search: true }, + } +); diff --git a/tests/integration/tools/mongodb/read/collectionIndexes.test.ts b/tests/integration/tools/mongodb/read/collectionIndexes.test.ts deleted file mode 100644 index d4b4ded04..000000000 --- a/tests/integration/tools/mongodb/read/collectionIndexes.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { IndexDirection } from "mongodb"; -import { - databaseCollectionParameters, - validateToolMetadata, - validateThrowsForInvalidArguments, - getResponseElements, - databaseCollectionInvalidArgs, -} from "../../../helpers.js"; -import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; -import { expect, it } from "vitest"; - -describeWithMongoDB("collectionIndexes tool", (integration) => { - validateToolMetadata( - integration, - "collection-indexes", - "Describe the indexes for a collection", - databaseCollectionParameters - ); - - validateThrowsForInvalidArguments(integration, "collection-indexes", databaseCollectionInvalidArgs); - - it("can inspect indexes on non-existent database", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "collection-indexes", - arguments: { database: "non-existent", collection: "people" }, - }); - - const elements = getResponseElements(response.content); - expect(elements).toHaveLength(1); - expect(elements[0]?.text).toEqual( - 'The indexes for "non-existent.people" cannot be determined because the collection does not exist.' - ); - }); - - it("returns the _id index for a new collection", async () => { - await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); - - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "collection-indexes", - arguments: { - database: integration.randomDbName(), - collection: "people", - }, - }); - - 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}'); - }); - - it("returns all indexes for a collection", async () => { - await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); - - const indexTypes: IndexDirection[] = [-1, 1, "2d", "2dsphere", "text", "hashed"]; - const indexNames: Map = new Map(); - for (const indexType of indexTypes) { - const indexName = await integration - .mongoClient() - .db(integration.randomDbName()) - .collection("people") - .createIndex({ [`prop_${indexType}`]: indexType }); - - indexNames.set(indexType, indexName); - } - - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "collection-indexes", - arguments: { - database: integration.randomDbName(), - collection: "people", - }, - }); - - const elements = getResponseElements(response.content); - 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}'); - - for (const indexType of indexTypes) { - let expectedDefinition = JSON.stringify({ [`prop_${indexType}`]: indexType }); - if (indexType === "text") { - expectedDefinition = '{"_fts":"text"'; - } - - expect(elements[1]?.text).toContain( - `Name: "${indexNames.get(indexType)}", definition: ${expectedDefinition}` - ); - } - }); - - validateAutoConnectBehavior(integration, "collection-indexes", () => { - return { - args: { database: integration.randomDbName(), collection: "coll1" }, - expectedResponse: `The indexes for "${integration.randomDbName()}.coll1" cannot be determined because the collection does not exist.`, - }; - }); -}); 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 399037964..000000000 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Collection } from "mongodb"; -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 { SearchIndexWithStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; - -const SEARCH_TIMEOUT = 60_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(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." - ); - }); -}); - -describeWithMongoDB( - "list-search-indexes tool in Atlas", - (integration) => { - let fooCollection: Collection; - - beforeEach(async () => { - await integration.connectMcpClient(); - fooCollection = integration.mongoClient().db("any").collection("foo"); - await waitUntilSearchIsReady(integration.mongoClient(), SEARCH_TIMEOUT); - }); - - 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 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 () => { - 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 () => { - await waitUntilSearchIndexIsQueryable(fooCollection, "default", SEARCH_TIMEOUT); - - 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 }, - } -);