diff --git a/src/common/config.ts b/src/common/config.ts index efcc7b4a6..8a32df931 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -201,6 +201,7 @@ export const defaultUserConfig: UserConfig = { "drop-database", "drop-collection", "delete-many", + "drop-index", ], transport: "stdio", httpPort: 3000, diff --git a/src/tools/mongodb/delete/dropIndex.ts b/src/tools/mongodb/delete/dropIndex.ts new file mode 100644 index 000000000..e87db4171 --- /dev/null +++ b/src/tools/mongodb/delete/dropIndex.ts @@ -0,0 +1,45 @@ +import z from "zod"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { type ToolArgs, type OperationType, formatUntrustedData } from "../../tool.js"; + +export class DropIndexTool extends MongoDBToolBase { + public name = "drop-index"; + protected description = "Drop an index for the provided database and collection."; + protected argsShape = { + ...DbOperationArgs, + indexName: z.string().nonempty().describe("The name of the index to be dropped."), + }; + public operationType: OperationType = "delete"; + + protected async execute({ + database, + collection, + indexName, + }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + 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, + }; + } + + protected getConfirmationMessage({ database, collection, indexName }: ToolArgs): string { + return ( + `You are about to drop the \`${indexName}\` index 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/tools.ts b/src/tools/mongodb/tools.ts index 4c705fa69..6e96b2ba6 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -20,12 +20,14 @@ 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 = [ ConnectTool, ListCollectionsTool, ListDatabasesTool, CollectionIndexesTool, + DropIndexTool, CreateIndexTool, CollectionSchemaTool, FindTool, diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts new file mode 100644 index 000000000..48023af55 --- /dev/null +++ b/tests/accuracy/dropIndex.test.ts @@ -0,0 +1,74 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import { Matcher } from "./sdk/matcher.js"; + +// We don't want to delete actual indexes +const mockedTools = { + "drop-index": ({ indexName, database, collection }: Record): CallToolResult => { + return { + content: [ + { + text: `Successfully dropped the index with name "${String(indexName)}" from the provided namespace "${String(database)}.${String(collection)}".`, + type: "text", + }, + ], + }; + }, +} as const; + +describeAccuracyTests([ + { + prompt: "Delete the index called year_1 from mflix.movies namespace", + expectedToolCalls: [ + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: "year_1", + }, + }, + ], + mockedTools, + }, + { + prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mflix", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + keys: { + title: "text", + }, + }, + }, + { + toolName: "collection-indexes", + parameters: { + database: "mflix", + collection: "movies", + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + }, + }, + { + toolName: "drop-index", + parameters: { + database: "mflix", + collection: "movies", + indexName: Matcher.string(), + }, + }, + ], + mockedTools, + }, +]); diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 130e8fb05..3e5b89b78 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -7,7 +7,7 @@ import { MCP_SERVER_CLI_SCRIPT } from "./constants.js"; import type { LLMToolCall } from "./accuracyResultStorage/resultStorage.js"; import type { VercelMCPClient, VercelMCPClientTools } from "./agent.js"; -type ToolResultGeneratorFn = (...parameters: unknown[]) => CallToolResult | Promise; +type ToolResultGeneratorFn = (parameters: Record) => CallToolResult | Promise; export type MockedTools = Record; /** @@ -44,7 +44,7 @@ export class AccuracyTestingClient { try { const toolResultGeneratorFn = this.mockedTools[toolName]; if (toolResultGeneratorFn) { - return await toolResultGeneratorFn(args); + return await toolResultGeneratorFn(args as Record); } return await tool.execute(args, options); 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 000000000..a1aac591e --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -0,0 +1,181 @@ +import { describe, beforeEach, it, afterEach, expect } from "vitest"; +import type { Collection } from "mongodb"; +import { + databaseCollectionInvalidArgs, + databaseCollectionParameters, + defaultDriverOptions, + defaultTestConfig, + getDataFromUntrustedContent, + getResponseContent, + setupIntegrationTest, + validateThrowsForInvalidArguments, + validateToolMetadata, +} from "../../../helpers.js"; +import { describeWithMongoDB, setupMongoDBIntegrationTest } from "../mongodbHelpers.js"; +import { createMockElicitInput } from "../../../../utils/elicitationMocks.js"; +import { Elicitation } from "../../../../../src/elicitation.js"; + +describeWithMongoDB("drop-index tool", (integration) => { + let moviesCollection: Collection; + let indexName: string; + beforeEach(async () => { + await integration.connectMcpClient(); + const client = integration.mongoClient(); + moviesCollection = client.db("mflix").collection("movies"); + await moviesCollection.insertMany([ + { + name: "Movie1", + year: 1994, + }, + { + name: "Movie2", + year: 2001, + }, + ]); + indexName = await moviesCollection.createIndex({ year: 1 }); + }); + + afterEach(async () => { + await moviesCollection.drop(); + }); + + validateToolMetadata(integration, "drop-index", "Drop an index for the provided database and collection.", [ + ...databaseCollectionParameters, + { + name: "indexName", + type: "string", + description: "The name of the index to be dropped.", + required: true, + }, + ]); + + validateThrowsForInvalidArguments(integration, "drop-index", [ + ...databaseCollectionInvalidArgs, + { database: "test", collection: "testColl", indexName: null }, + { database: "test", collection: "testColl", indexName: undefined }, + { database: "test", collection: "testColl", indexName: [] }, + { database: "test", collection: "testColl", indexName: true }, + { database: "test", collection: "testColl", indexName: false }, + { database: "test", collection: "testColl", indexName: 0 }, + { database: "test", collection: "testColl", indexName: 12 }, + { database: "test", collection: "testColl", indexName: "" }, + ]); + + describe.each([ + { + database: "mflix", + collection: "non-existent", + }, + { + database: "non-db", + collection: "non-coll", + }, + ])( + "when attempting to delete an index from non-existent namespace - $database $collection", + ({ database, collection }) => { + it("should fail with error", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database, collection, indexName: "non-existent" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual(`Error running drop-index: ns not found ${database}.${collection}`); + }); + } + ); + + describe("when attempting to delete an index that does not exist", () => { + it("should fail with error", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName: "non-existent" }, + }); + expect(response.isError).toBe(true); + const content = getResponseContent(response.content); + expect(content).toEqual(`Error running drop-index: index not found with name [non-existent]`); + }); + }); + + describe("when attempting to delete an index that exists", () => { + it("should succeed", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-index", + // The index is created in beforeEach + arguments: { database: "mflix", collection: "movies", indexName: indexName }, + }); + expect(response.isError).toBe(undefined); + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped the index from the provided namespace.`); + const data = getDataFromUntrustedContent(content); + expect(JSON.parse(data)).toMatchObject({ indexName, namespace: "mflix.movies" }); + }); + }); +}); + +describe("drop-index tool - when invoked via an elicitation enabled client", () => { + const mockElicitInput = createMockElicitInput(); + const mdbIntegration = setupMongoDBIntegrationTest(); + const integration = setupIntegrationTest( + () => defaultTestConfig, + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); + let moviesCollection: Collection; + let indexName: string; + + beforeEach(async () => { + moviesCollection = mdbIntegration.mongoClient().db("mflix").collection("movies"); + await moviesCollection.insertMany([ + { name: "Movie1", year: 1994 }, + { name: "Movie2", year: 2001 }, + ]); + indexName = await moviesCollection.createIndex({ year: 1 }); + await integration.mcpClient().callTool({ + name: "connect", + arguments: { + connectionString: mdbIntegration.connectionString(), + }, + }); + }); + + afterEach(async () => { + await moviesCollection.drop(); + }); + + it("should ask for confirmation before proceeding with tool call", async () => { + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmYes(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName }, + }); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the `year_1` index from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(1); + }); + + it("should not drop the index if the confirmation was not provided", async () => { + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + mockElicitInput.confirmNo(); + await integration.mcpClient().callTool({ + name: "drop-index", + arguments: { database: "mflix", collection: "movies", indexName }, + }); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the `year_1` index from the `mflix.movies` namespace" + ), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2); + }); +}); diff --git a/tests/integration/transports/stdio.test.ts b/tests/integration/transports/stdio.test.ts index aaa61d638..b5ed80840 100644 --- a/tests/integration/transports/stdio.test.ts +++ b/tests/integration/transports/stdio.test.ts @@ -32,7 +32,7 @@ describeWithMongoDB("StdioRunner", (integration) => { const response = await client.listTools(); expect(response).toBeDefined(); expect(response.tools).toBeDefined(); - expect(response.tools).toHaveLength(21); + expect(response.tools).toHaveLength(22); const sortedTools = response.tools.sort((a, b) => a.name.localeCompare(b.name)); expect(sortedTools[0]?.name).toBe("aggregate");