diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts index e69d92dc8..dea72bf83 100644 --- a/src/tools/mongodb/delete/dropIndex.ts +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -3,7 +3,6 @@ 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"; @@ -60,9 +59,8 @@ export class DropIndexTool extends MongoDBToolBase { { 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) { + const indexes = await provider.getSearchIndexes(database, collection, indexName); + if (indexes.length === 0) { return { content: formatUntrustedData( "Index does not exist in the provided namespace.", 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/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/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 }, - } -);