From 632680da3c9b9fc5e60339cdcd0a1425df91d7be Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 30 Apr 2025 18:13:30 +0200 Subject: [PATCH 1/3] feat: add support for managing search indexes --- src/tools/mongodb/create/createSearchIndex.ts | 93 +++++++++++++++++++ src/tools/mongodb/delete/dropIndex.ts | 29 ++++++ src/tools/mongodb/read/collectionIndexes.ts | 13 ++- src/tools/mongodb/tools.ts | 36 ++++--- 4 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 src/tools/mongodb/create/createSearchIndex.ts create mode 100644 src/tools/mongodb/delete/dropIndex.ts diff --git a/src/tools/mongodb/create/createSearchIndex.ts b/src/tools/mongodb/create/createSearchIndex.ts new file mode 100644 index 00000000..2b7b8160 --- /dev/null +++ b/src/tools/mongodb/create/createSearchIndex.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs, OperationType } from "../../tool.js"; +import { IndexDirection } from "mongodb"; + +export class CreateSearchIndexTool extends MongoDBToolBase { + protected name = "create-search-index"; + protected description = "Create an Atlas Search index for a collection"; + protected argsShape = { + ...DbOperationArgs, + name: z.string().optional().describe("The name of the index"), + type: z.enum(["search", "vectorSearch"]).optional().default("search").describe("The type of the index"), + analyzer: z + .string() + .optional() + .default("lucene.standard") + .describe( + "The analyzer to use for the index. Can be one of the built-in lucene analyzers (`lucene.standard`, `lucene.simple`, `lucene.whitespace`, `lucene.keyword`), a language-specific analyzer, such as `lucene.cjk` or `lucene.czech`, or a custom analyzer defined in the Atlas UI." + ), + mappings: z.object({ + dynamic: z + .boolean() + .optional() + .default(false) + .describe( + "Enables or disables dynamic mapping of fields for this index. If set to true, Atlas Search recursively indexes all dynamically indexable fields. If set to false, you must specify individual fields to index using mappings.fields." + ), + fields: z + .record( + z.string().describe("The field name"), + z + .object({ + type: z + .enum([ + "autocomplete", + "boolean", + "date", + "document", + "embeddedDocuments", + "geo", + "knnVector", + "number", + "objectId", + "string", + "token", + "uuid", + ]) + .describe("The field type"), + }) + .passthrough() + + .describe( + "The field index definition. It must contain the field type, as well as any additional options for that field type." + ) + ) + .optional() + .describe("The field mapping definitions. If `dynamic` is set to false, this is required."), + }), + }; + + protected operationType: OperationType = "create"; + + protected async execute({ + database, + collection, + name, + type, + analyzer, + mappings, + }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + const indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + type, + definition: { + analyzer, + mappings, + }, + }, + ]); + + return { + content: [ + { + text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}"`, + type: "text", + }, + ], + }; + } +} diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts new file mode 100644 index 00000000..61e1e370 --- /dev/null +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -0,0 +1,29 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs, OperationType } from "../../tool.js"; +import { z } from "zod"; + +export class DropIndexTool extends MongoDBToolBase { + protected name = "drop-index"; + protected description = "Removes an index from a collection."; + protected argsShape = { + ...DbOperationArgs, + name: z.string().describe("The name of the index to drop"), + }; + protected operationType: OperationType = "delete"; + + protected async execute({ database, collection, name }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + await provider.mongoClient.db(database).collection(collection).dropIndex(name); + await provider.dropSearchIndex(database, collection, name); + + return { + content: [ + { + text: `Successfully dropped index "${name}" in "${database}.${collection}"`, + type: "text", + }, + ], + }; + } +} diff --git a/src/tools/mongodb/read/collectionIndexes.ts b/src/tools/mongodb/read/collectionIndexes.ts index cc0a141b..71316883 100644 --- a/src/tools/mongodb/read/collectionIndexes.ts +++ b/src/tools/mongodb/read/collectionIndexes.ts @@ -11,6 +11,7 @@ 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 searchIndexes = await provider.getSearchIndexes(database, collection); return { content: [ @@ -18,12 +19,18 @@ export class CollectionIndexesTool extends MongoDBToolBase { text: `Found ${indexes.length} indexes in the collection "${collection}":`, type: "text", }, - ...(indexes.map((indexDefinition) => { + ...indexes.map((indexDefinition) => { return { text: `Name "${indexDefinition.name}", definition: ${JSON.stringify(indexDefinition.key)}`, type: "text", - }; - }) as { text: string; type: "text" }[]), + } as const; + }), + ...searchIndexes.map((indexDefinition) => { + return { + text: `Search index name: "${indexDefinition.name}", status: ${indexDefinition.status}, definition: ${JSON.stringify(indexDefinition.latestDefinition)}`, + type: "text", + } as const; + }), ], }; } diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index 523f45ca..40240bda 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -1,7 +1,7 @@ // TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled // import { ConnectTool } from "./metadata/connect.js"; import { ListCollectionsTool } from "./metadata/listCollections.js"; -import { CollectionIndexesTool } from "./read/collectionIndexes.js"; +import { CollectionIndexesTool as ListIndexesTool } from "./read/collectionIndexes.js"; import { ListDatabasesTool } from "./metadata/listDatabases.js"; import { CreateIndexTool } from "./create/createIndex.js"; import { CollectionSchemaTool } from "./metadata/collectionSchema.js"; @@ -19,27 +19,35 @@ import { DropCollectionTool } from "./delete/dropCollection.js"; import { ExplainTool } from "./metadata/explain.js"; import { CreateCollectionTool } from "./create/createCollection.js"; import { LogsTool } from "./metadata/logs.js"; +import { CreateSearchIndexTool } from "./create/createSearchIndex.js"; +import { DropIndexTool } from "./delete/dropIndex.js"; export const MongoDbTools = [ // TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled // ConnectTool, + CreateCollectionTool, ListCollectionsTool, - ListDatabasesTool, - CollectionIndexesTool, - CreateIndexTool, CollectionSchemaTool, - FindTool, - InsertManyTool, - DeleteManyTool, CollectionStorageSizeTool, - CountTool, - DbStatsTool, - AggregateTool, - UpdateManyTool, RenameCollectionTool, - DropDatabaseTool, DropCollectionTool, - ExplainTool, - CreateCollectionTool, + + ListDatabasesTool, + DropDatabaseTool, + DbStatsTool, LogsTool, + + FindTool, + AggregateTool, + CountTool, + ExplainTool, + + InsertManyTool, + DeleteManyTool, + UpdateManyTool, + + CreateIndexTool, + CreateSearchIndexTool, + ListIndexesTool, + DropIndexTool, ]; From 87ea625b9ebb33d2db09fc3028b7247719ca9375 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Thu, 1 May 2025 14:36:09 +0200 Subject: [PATCH 2/3] Add tests --- src/tools/mongodb/create/createSearchIndex.ts | 1 - src/tools/mongodb/delete/dropIndex.ts | 30 ++++- src/tools/mongodb/read/collectionIndexes.ts | 18 ++- tests/integration/helpers.ts | 4 + .../tools/atlas-search/atlasSearchHelpers.ts | 75 ++++++++++++ .../read/collectionIndexes.test.ts | 48 ++++++++ tests/integration/tools/atlas/atlasHelpers.ts | 59 +++++++-- .../integration/tools/atlas/clusters.test.ts | 49 +------- .../tools/mongodb/delete/dropIndex.test.ts | 114 ++++++++++++++++++ .../tools/mongodb/mongodbHelpers.ts | 4 +- 10 files changed, 339 insertions(+), 63 deletions(-) create mode 100644 tests/integration/tools/atlas-search/atlasSearchHelpers.ts create mode 100644 tests/integration/tools/atlas-search/read/collectionIndexes.test.ts create mode 100644 tests/integration/tools/mongodb/delete/dropIndex.test.ts diff --git a/src/tools/mongodb/create/createSearchIndex.ts b/src/tools/mongodb/create/createSearchIndex.ts index 2b7b8160..5d727dbb 100644 --- a/src/tools/mongodb/create/createSearchIndex.ts +++ b/src/tools/mongodb/create/createSearchIndex.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; -import { IndexDirection } from "mongodb"; export class CreateSearchIndexTool extends MongoDBToolBase { protected name = "create-search-index"; diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts index 61e1e370..2cfb703a 100644 --- a/src/tools/mongodb/delete/dropIndex.ts +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -2,6 +2,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; import { z } from "zod"; +import { MongoServerError } from "mongodb"; export class DropIndexTool extends MongoDBToolBase { protected name = "drop-index"; @@ -15,7 +16,16 @@ export class DropIndexTool extends MongoDBToolBase { protected async execute({ database, collection, name }: ToolArgs): Promise { const provider = await this.ensureConnected(); await provider.mongoClient.db(database).collection(collection).dropIndex(name); - await provider.dropSearchIndex(database, collection, name); + try { + await provider.dropSearchIndex(database, collection, name); + } catch (error) { + if (error instanceof MongoServerError && error.codeName === "SearchNotEnabled") { + // If search is not enabled (e.g. due to connecting to a non-Atlas cluster), we can ignore the error + // and return an empty array for search indexes. + } else { + throw error; + } + } return { content: [ @@ -26,4 +36,22 @@ export class DropIndexTool extends MongoDBToolBase { ], }; } + + protected handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + if (error instanceof Error && "codeName" in error && error.codeName === "NamespaceNotFound") { + return { + content: [ + { + text: `Cannot drop index "${args.name}" because the namespace "${args.database}.${args.collection}" does not exist.`, + type: "text", + }, + ], + }; + } + + return super.handleError(error, args); + } } diff --git a/src/tools/mongodb/read/collectionIndexes.ts b/src/tools/mongodb/read/collectionIndexes.ts index 71316883..2303b6a0 100644 --- a/src/tools/mongodb/read/collectionIndexes.ts +++ b/src/tools/mongodb/read/collectionIndexes.ts @@ -1,6 +1,8 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; +import { Document } from "bson"; +import { MongoServerError } from "mongodb"; export class CollectionIndexesTool extends MongoDBToolBase { protected name = "collection-indexes"; @@ -11,12 +13,24 @@ 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 searchIndexes = await provider.getSearchIndexes(database, collection); + + let searchIndexes: Document[]; + try { + searchIndexes = await provider.getSearchIndexes(database, collection); + } catch (error) { + if (error instanceof MongoServerError && error.codeName === "SearchNotEnabled") { + // If search is not enabled (e.g. due to connecting to a non-Atlas cluster), we can ignore the error + // and return an empty array for search indexes. + searchIndexes = []; + } else { + throw error; + } + } return { content: [ { - text: `Found ${indexes.length} indexes in the collection "${collection}":`, + text: `Found ${indexes.length + searchIndexes.length} indexes in the collection "${collection}":`, type: "text", }, ...indexes.map((indexDefinition) => { diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index bacc89b9..3b74b9f2 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -224,3 +224,7 @@ export function validateThrowsForInvalidArguments( export function expectDefined(arg: T): asserts arg is Exclude { expect(arg).toBeDefined(); } + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/integration/tools/atlas-search/atlasSearchHelpers.ts b/tests/integration/tools/atlas-search/atlasSearchHelpers.ts new file mode 100644 index 00000000..da0c95bc --- /dev/null +++ b/tests/integration/tools/atlas-search/atlasSearchHelpers.ts @@ -0,0 +1,75 @@ +import { ObjectId } from "bson"; +import { defaultTestConfig, IntegrationTest, setupIntegrationTest } from "../../helpers.js"; +import { deleteAndWaitCluster, waitClusterState, withProject } from "../atlas/atlasHelpers.js"; + +export function describeWithAtlasSearch( + name: string, + fn: (integration: IntegrationTest & { connectMcpClient: () => Promise }) => void +): void { + const describeFn = + process.env.MDB_MCP_API_CLIENT_ID?.length && process.env.MDB_MCP_API_CLIENT_SECRET?.length + ? describe + : describe.skip; + + describeFn("atlas-search", () => { + const integration = setupIntegrationTest(() => ({ + ...defaultTestConfig, + apiClientId: process.env.MDB_MCP_API_CLIENT_ID, + apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET, + })); + + describe(name, () => { + withProject(integration, ({ getProjectId }) => { + const clusterName = "ClusterTest-" + new ObjectId().toString(); + beforeAll(async () => { + const projectId = getProjectId(); + + await integration.mcpClient().callTool({ + name: "atlas-create-free-cluster", + arguments: { + projectId, + name: clusterName, + region: "US_EAST_1", + }, + }); + + await waitClusterState(integration.mcpServer().session, projectId, clusterName, "IDLE"); + await integration.mcpServer().session.apiClient.createProjectIpAccessList({ + params: { + path: { + groupId: projectId, + }, + }, + body: [ + { + comment: "MCP test", + cidrBlock: "0.0.0.0/0", + }, + ], + }); + }); + + afterAll(async () => { + const projectId = getProjectId(); + + const session = integration.mcpServer().session; + + await deleteAndWaitCluster(session, projectId, clusterName); + }); + + fn({ + ...integration, + connectMcpClient: async () => { + await integration.mcpClient().callTool({ + name: "atlas-connect-cluster", + arguments: { projectId: getProjectId(), clusterName }, + }); + + expect(integration.mcpServer().session.connectedAtlasCluster).toBeDefined(); + expect(integration.mcpServer().session.serviceProvider).toBeDefined(); + }, + }); + }); + }); + }); +} diff --git a/tests/integration/tools/atlas-search/read/collectionIndexes.test.ts b/tests/integration/tools/atlas-search/read/collectionIndexes.test.ts new file mode 100644 index 00000000..3c35a054 --- /dev/null +++ b/tests/integration/tools/atlas-search/read/collectionIndexes.test.ts @@ -0,0 +1,48 @@ +import { ObjectId } from "bson"; +import { expectDefined, getResponseElements } from "../../../helpers.js"; +import { describeWithAtlasSearch } from "../atlasSearchHelpers.js"; + +describeWithAtlasSearch("collectionIndexes tool", (integration) => { + it("can inspect search indexes", async () => { + await integration.connectMcpClient(); + + const provider = integration.mcpServer().session.serviceProvider; + expectDefined(provider); + + const database = new ObjectId().toString(); + + await provider.mongoClient + .db(database) + .collection("coll1") + .insertMany([ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 35 }, + ]); + + const name = await provider.mongoClient + .db(database) + .collection("coll1") + .createSearchIndex({ + name: "searchIndex1", + definition: { + mappings: { + dynamic: true, + }, + analyzer: "lucene.danish", + }, + }); + + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database, collection: "coll1" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(3); + expect(elements[0].text).toEqual(`Found 2 indexes in the collection "coll1":`); + expect(elements[1].text).toEqual('Name "_id_", definition: {"_id":1}'); + expect(elements[2].text).toContain(`Search index name: "${name}"`); + expect(elements[2].text).toContain('"analyzer":"lucene.danish"'); + }); +}); diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index aecf0479..861f0dac 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -1,11 +1,12 @@ import { ObjectId } from "mongodb"; import { Group } from "../../../../src/common/atlas/openapi.js"; import { ApiClient } from "../../../../src/common/atlas/apiClient.js"; -import { setupIntegrationTest, IntegrationTest, defaultTestConfig } from "../../helpers.js"; +import { setupIntegrationTest, IntegrationTest, defaultTestConfig, sleep } from "../../helpers.js"; +import { Session } from "../../../../src/session.js"; export type IntegrationTestFunction = (integration: IntegrationTest) => void; -export function describeWithAtlas(name: string, fn: IntegrationTestFunction) { +export function describeWithAtlas(name: string, fn: IntegrationTestFunction): void { const testDefinition = () => { const integration = setupIntegrationTest(() => ({ ...defaultTestConfig, @@ -21,7 +22,8 @@ export function describeWithAtlas(name: string, fn: IntegrationTestFunction) { if (!process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length) { return describe.skip("atlas", testDefinition); } - return describe("atlas", testDefinition); + + describe("atlas", testDefinition); } interface ProjectTestArgs { @@ -30,8 +32,8 @@ interface ProjectTestArgs { type ProjectTestFunction = (args: ProjectTestArgs) => void; -export function withProject(integration: IntegrationTest, fn: ProjectTestFunction) { - return describe("project", () => { +export function withProject(integration: IntegrationTest, fn: ProjectTestFunction): void { + describe("with project", () => { let projectId: string = ""; beforeAll(async () => { @@ -57,9 +59,7 @@ export function withProject(integration: IntegrationTest, fn: ProjectTestFunctio getProjectId: () => projectId, }; - describe("with project", () => { - fn(args); - }); + fn(args); }); } @@ -104,3 +104,46 @@ async function createProject(apiClient: ApiClient): Promise { return group; } + +export async function waitClusterState(session: Session, projectId: string, clusterName: string, state: string) { + while (true) { + const cluster = await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (cluster?.stateName === state) { + return; + } + await sleep(1000); + } +} + +export async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) { + await session.apiClient.deleteCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + while (true) { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + await sleep(1000); + } catch { + break; + } + } +} diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index f9e07943..bf423e04 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,55 +1,8 @@ import { Session } from "../../../../src/session.js"; import { expectDefined } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId } from "./atlasHelpers.js"; +import { describeWithAtlas, withProject, randomId, waitClusterState, deleteAndWaitCluster } from "./atlasHelpers.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) { - await session.apiClient.deleteCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - while (true) { - try { - await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - await sleep(1000); - } catch { - break; - } - } -} - -async function waitClusterState(session: Session, projectId: string, clusterName: string, state: string) { - while (true) { - const cluster = await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - if (cluster?.stateName === state) { - return; - } - await sleep(1000); - } -} - describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId }) => { const clusterName = "ClusterTest-" + randomId; diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts new file mode 100644 index 00000000..b565409f --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -0,0 +1,114 @@ +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +import { + getResponseContent, + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, +} from "../../../helpers.js"; + +describeWithMongoDB("dropIndex tool", (integration) => { + validateToolMetadata(integration, "drop-index", "Removes an index from a collection.", [ + ...databaseCollectionParameters, + { + name: "name", + type: "string", + description: "The name of the index to drop", + required: true, + }, + ]); + + validateThrowsForInvalidArguments(integration, "drop-index", [ + {}, + { collection: "bar", name: "_id_" }, + { database: "test", name: "_id_" }, + { collection: "bar", database: "test" }, + { collection: "bar", database: 123, name: "_id_" }, + { collection: [], database: "test", name: "_id_" }, + { collection: "bar", database: "test", name: {} }, + ]); + + it("returns an error when dropping from non-existing collection", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + name: "_id_", + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain( + `Cannot drop index "_id_" because the namespace "${integration.randomDbName()}.coll1" does not exist.` + ); + }); + + it("returns an error when dropping a non-existent index", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + name: "non-existent-index", + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain("index not found with name [non-existent-index]"); + }); + + it("removes an existing index", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .createIndex({ a: 1 }, { name: "index-a" }); + + let indexes = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .listIndexes() + .toArray(); + expect(indexes).toHaveLength(2); + expect(indexes[0]).toHaveProperty("name", "_id_"); + expect(indexes[1]).toHaveProperty("name", "index-a"); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + name: "index-a", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped index "index-a" in "${integration.randomDbName()}.coll1"`); + indexes = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .listIndexes() + .toArray(); + expect(indexes).toHaveLength(1); + expect(indexes[0]).toHaveProperty("name", "_id_"); + }); + + validateAutoConnectBehavior(integration, "drop-index", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + name: "index-a", + }, + expectedResponse: `Cannot drop index "_id_" because the namespace "${integration.randomDbName()}.coll1" does not exist.`, + }; + }); +}); diff --git a/tests/integration/tools/mongodb/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index 39ae86fa..610a4b6f 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -19,7 +19,7 @@ export function describeWithMongoDB( fn: (integration: IntegrationTest & MongoDBIntegrationTest & { connectMcpClient: () => Promise }) => void, getUserConfig: (mdbIntegration: MongoDBIntegrationTest) => UserConfig = () => defaultTestConfig, describeFn = describe -) { +): void { describeFn(name, () => { const mdbIntegration = setupMongoDBIntegrationTest(); const integration = setupIntegrationTest(() => ({ @@ -76,8 +76,6 @@ export function setupMongoDBIntegrationTest(): MongoDBIntegrationTest { let dbsDir = path.join(tmpDir, "mongodb-runner", "dbs"); for (let i = 0; i < 10; i++) { try { - // TODO: Fix this type once mongodb-runner is updated. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call mongoCluster = await MongoCluster.start({ tmpDir: dbsDir, logDir: path.join(tmpDir, "mongodb-runner", "logs"), From f556cc3691a62d8e98f374be4ebaa14c4a555642 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Thu, 1 May 2025 15:49:41 +0200 Subject: [PATCH 3/3] fix tests --- .../tools/atlas-search/atlasSearchHelpers.ts | 12 ++----- .../tools/atlas/accessLists.test.ts | 17 --------- tests/integration/tools/atlas/atlasHelpers.ts | 36 +++++++++++++------ .../integration/tools/atlas/clusters.test.ts | 14 ++------ tests/integration/tools/atlas/dbUsers.test.ts | 21 ++--------- tests/integration/tools/atlas/orgs.test.ts | 2 +- .../integration/tools/atlas/projects.test.ts | 4 +-- 7 files changed, 35 insertions(+), 71 deletions(-) diff --git a/tests/integration/tools/atlas-search/atlasSearchHelpers.ts b/tests/integration/tools/atlas-search/atlasSearchHelpers.ts index da0c95bc..5e1d2b25 100644 --- a/tests/integration/tools/atlas-search/atlasSearchHelpers.ts +++ b/tests/integration/tools/atlas-search/atlasSearchHelpers.ts @@ -1,6 +1,6 @@ import { ObjectId } from "bson"; import { defaultTestConfig, IntegrationTest, setupIntegrationTest } from "../../helpers.js"; -import { deleteAndWaitCluster, waitClusterState, withProject } from "../atlas/atlasHelpers.js"; +import { waitClusterState, withProject } from "../atlas/atlasHelpers.js"; export function describeWithAtlasSearch( name: string, @@ -20,7 +20,7 @@ export function describeWithAtlasSearch( describe(name, () => { withProject(integration, ({ getProjectId }) => { - const clusterName = "ClusterTest-" + new ObjectId().toString(); + const clusterName = `ClusterTest-${new ObjectId()}`; beforeAll(async () => { const projectId = getProjectId(); @@ -49,14 +49,6 @@ export function describeWithAtlasSearch( }); }); - afterAll(async () => { - const projectId = getProjectId(); - - const session = integration.mcpServer().session; - - await deleteAndWaitCluster(session, projectId, clusterName); - }); - fn({ ...integration, connectMcpClient: async () => { diff --git a/tests/integration/tools/atlas/accessLists.test.ts b/tests/integration/tools/atlas/accessLists.test.ts index a194a351..b10ea4f2 100644 --- a/tests/integration/tools/atlas/accessLists.test.ts +++ b/tests/integration/tools/atlas/accessLists.test.ts @@ -22,23 +22,6 @@ describeWithAtlas("ip access lists", (integration) => { values.push(ipInfo.currentIpv4Address); }); - afterAll(async () => { - const apiClient = integration.mcpServer().session.apiClient; - - const projectId = getProjectId(); - - for (const value of values) { - await apiClient.deleteProjectIpAccessList({ - params: { - path: { - groupId: projectId, - entryValue: value, - }, - }, - }); - } - }); - describe("atlas-create-access-list", () => { it("should have correct metadata", async () => { const { tools } = await integration.mcpClient().listTools(); diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 861f0dac..884b5371 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -35,17 +35,37 @@ type ProjectTestFunction = (args: ProjectTestArgs) => void; export function withProject(integration: IntegrationTest, fn: ProjectTestFunction): void { describe("with project", () => { let projectId: string = ""; + const projectName = `testProj-${new ObjectId()}`; beforeAll(async () => { const apiClient = integration.mcpServer().session.apiClient; - const group = await createProject(apiClient); + const group = await createProject(apiClient, projectName); projectId = group.id || ""; }); afterAll(async () => { const apiClient = integration.mcpServer().session.apiClient; + const clusters = await apiClient.listClusters({ + params: { + path: { + groupId: projectId, + }, + }, + }); + + const deletePromises = + clusters?.results?.map((cluster) => { + if (cluster.name) { + return deleteAndWaitCluster(integration.mcpServer().session, projectId, cluster.name); + } + + return Promise.resolve(); + }) ?? []; + + await Promise.all(deletePromises); + await apiClient.deleteProject({ params: { path: { @@ -55,11 +75,9 @@ export function withProject(integration: IntegrationTest, fn: ProjectTestFunctio }); }); - const args = { + fn({ getProjectId: () => projectId, - }; - - fn(args); + }); }); } @@ -81,11 +99,7 @@ export function parseTable(text: string): Record[] { }); } -export const randomId = new ObjectId().toString(); - -async function createProject(apiClient: ApiClient): Promise { - const projectName: string = `testProj-` + randomId; - +async function createProject(apiClient: ApiClient, projectName: string): Promise { const orgs = await apiClient.listOrganizations(); if (!orgs?.results?.length || !orgs.results[0].id) { throw new Error("No orgs found"); @@ -122,7 +136,7 @@ export async function waitClusterState(session: Session, projectId: string, clus } } -export async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) { +async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) { await session.apiClient.deleteCluster({ params: { path: { diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index bf423e04..468477ac 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,19 +1,11 @@ -import { Session } from "../../../../src/session.js"; +import { ObjectId } from "bson"; import { expectDefined } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId, waitClusterState, deleteAndWaitCluster } from "./atlasHelpers.js"; +import { describeWithAtlas, withProject, waitClusterState } from "./atlasHelpers.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId }) => { - const clusterName = "ClusterTest-" + randomId; - - afterAll(async () => { - const projectId = getProjectId(); - - const session: Session = integration.mcpServer().session; - - await deleteAndWaitCluster(session, projectId, clusterName); - }); + const clusterName = `ClusterTest-${new ObjectId()}`; describe("atlas-create-free-cluster", () => { it("should have correct metadata", async () => { diff --git a/tests/integration/tools/atlas/dbUsers.test.ts b/tests/integration/tools/atlas/dbUsers.test.ts index 892bb89e..7f575fbe 100644 --- a/tests/integration/tools/atlas/dbUsers.test.ts +++ b/tests/integration/tools/atlas/dbUsers.test.ts @@ -1,26 +1,11 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { Session } from "../../../../src/session.js"; -import { describeWithAtlas, withProject, randomId } from "./atlasHelpers.js"; +import { describeWithAtlas, withProject } from "./atlasHelpers.js"; import { expectDefined } from "../../helpers.js"; +import { ObjectId } from "bson"; describeWithAtlas("db users", (integration) => { - const userName = "testuser-" + randomId; + const userName = `testuser-${new ObjectId()}`; withProject(integration, ({ getProjectId }) => { - afterAll(async () => { - const projectId = getProjectId(); - - const session: Session = integration.mcpServer().session; - await session.apiClient.deleteDatabaseUser({ - params: { - path: { - groupId: projectId, - username: userName, - databaseName: "admin", - }, - }, - }); - }); - describe("atlas-create-db-user", () => { it("should have correct metadata", async () => { const { tools } = await integration.mcpClient().listTools(); diff --git a/tests/integration/tools/atlas/orgs.test.ts b/tests/integration/tools/atlas/orgs.test.ts index 83143404..d5bd8f18 100644 --- a/tests/integration/tools/atlas/orgs.test.ts +++ b/tests/integration/tools/atlas/orgs.test.ts @@ -18,7 +18,7 @@ describeWithAtlas("orgs", (integration) => { expect(response.content).toHaveLength(1); const data = parseTable(response.content[0].text as string); expect(data).toHaveLength(1); - expect(data[0]["Organization Name"]).toEqual("MongoDB MCP Test"); + expect(data[0]["Organization Name"]).toBeDefined(); }); }); }); diff --git a/tests/integration/tools/atlas/projects.test.ts b/tests/integration/tools/atlas/projects.test.ts index 7d773c7e..09a14570 100644 --- a/tests/integration/tools/atlas/projects.test.ts +++ b/tests/integration/tools/atlas/projects.test.ts @@ -3,10 +3,8 @@ import { ObjectId } from "mongodb"; import { parseTable, describeWithAtlas } from "./atlasHelpers.js"; import { expectDefined } from "../../helpers.js"; -const randomId = new ObjectId().toString(); - describeWithAtlas("projects", (integration) => { - const projName = "testProj-" + randomId; + const projName = `testProj-${new ObjectId()}`; afterAll(async () => { const session = integration.mcpServer().session;