Skip to content

Commit 7e80114

Browse files
feat: introduces drop-index tool for regular index (#644)
1 parent faad36d commit 7e80114

File tree

7 files changed

+306
-3
lines changed

7 files changed

+306
-3
lines changed

src/common/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export const defaultUserConfig: UserConfig = {
201201
"drop-database",
202202
"drop-collection",
203203
"delete-many",
204+
"drop-index",
204205
],
205206
transport: "stdio",
206207
httpPort: 3000,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import z from "zod";
2+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3+
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
4+
import { type ToolArgs, type OperationType, formatUntrustedData } from "../../tool.js";
5+
6+
export class DropIndexTool extends MongoDBToolBase {
7+
public name = "drop-index";
8+
protected description = "Drop an index for the provided database and collection.";
9+
protected argsShape = {
10+
...DbOperationArgs,
11+
indexName: z.string().nonempty().describe("The name of the index to be dropped."),
12+
};
13+
public operationType: OperationType = "delete";
14+
15+
protected async execute({
16+
database,
17+
collection,
18+
indexName,
19+
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
20+
const provider = await this.ensureConnected();
21+
const result = await provider.runCommand(database, {
22+
dropIndexes: collection,
23+
index: indexName,
24+
});
25+
26+
return {
27+
content: formatUntrustedData(
28+
`${result.ok ? "Successfully dropped" : "Failed to drop"} the index from the provided namespace.`,
29+
JSON.stringify({
30+
indexName,
31+
namespace: `${database}.${collection}`,
32+
})
33+
),
34+
isError: result.ok ? undefined : true,
35+
};
36+
}
37+
38+
protected getConfirmationMessage({ database, collection, indexName }: ToolArgs<typeof this.argsShape>): string {
39+
return (
40+
`You are about to drop the \`${indexName}\` index from the \`${database}.${collection}\` namespace:\n\n` +
41+
"This operation will permanently remove the index and might affect the performance of queries relying on this index.\n\n" +
42+
"**Do you confirm the execution of the action?**"
43+
);
44+
}
45+
}

src/tools/mongodb/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import { CreateCollectionTool } from "./create/createCollection.js";
2020
import { LogsTool } from "./metadata/logs.js";
2121
import { ExportTool } from "./read/export.js";
2222
import { ListSearchIndexesTool } from "./search/listSearchIndexes.js";
23+
import { DropIndexTool } from "./delete/dropIndex.js";
2324

2425
export const MongoDbTools = [
2526
ConnectTool,
2627
ListCollectionsTool,
2728
ListDatabasesTool,
2829
CollectionIndexesTool,
30+
DropIndexTool,
2931
CreateIndexTool,
3032
CollectionSchemaTool,
3133
FindTool,

tests/accuracy/dropIndex.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2+
import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js";
3+
import { Matcher } from "./sdk/matcher.js";
4+
5+
// We don't want to delete actual indexes
6+
const mockedTools = {
7+
"drop-index": ({ indexName, database, collection }: Record<string, unknown>): CallToolResult => {
8+
return {
9+
content: [
10+
{
11+
text: `Successfully dropped the index with name "${String(indexName)}" from the provided namespace "${String(database)}.${String(collection)}".`,
12+
type: "text",
13+
},
14+
],
15+
};
16+
},
17+
} as const;
18+
19+
describeAccuracyTests([
20+
{
21+
prompt: "Delete the index called year_1 from mflix.movies namespace",
22+
expectedToolCalls: [
23+
{
24+
toolName: "drop-index",
25+
parameters: {
26+
database: "mflix",
27+
collection: "movies",
28+
indexName: "year_1",
29+
},
30+
},
31+
],
32+
mockedTools,
33+
},
34+
{
35+
prompt: "First create a text index on field 'title' in 'mflix.movies' namespace and then drop all the indexes from 'mflix.movies' namespace",
36+
expectedToolCalls: [
37+
{
38+
toolName: "create-index",
39+
parameters: {
40+
database: "mflix",
41+
collection: "movies",
42+
name: Matcher.anyOf(Matcher.undefined, Matcher.string()),
43+
keys: {
44+
title: "text",
45+
},
46+
},
47+
},
48+
{
49+
toolName: "collection-indexes",
50+
parameters: {
51+
database: "mflix",
52+
collection: "movies",
53+
},
54+
},
55+
{
56+
toolName: "drop-index",
57+
parameters: {
58+
database: "mflix",
59+
collection: "movies",
60+
indexName: Matcher.string(),
61+
},
62+
},
63+
{
64+
toolName: "drop-index",
65+
parameters: {
66+
database: "mflix",
67+
collection: "movies",
68+
indexName: Matcher.string(),
69+
},
70+
},
71+
],
72+
mockedTools,
73+
},
74+
]);

tests/accuracy/sdk/accuracyTestingClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { MCP_SERVER_CLI_SCRIPT } from "./constants.js";
77
import type { LLMToolCall } from "./accuracyResultStorage/resultStorage.js";
88
import type { VercelMCPClient, VercelMCPClientTools } from "./agent.js";
99

10-
type ToolResultGeneratorFn = (...parameters: unknown[]) => CallToolResult | Promise<CallToolResult>;
10+
type ToolResultGeneratorFn = (parameters: Record<string, unknown>) => CallToolResult | Promise<CallToolResult>;
1111
export type MockedTools = Record<string, ToolResultGeneratorFn>;
1212

1313
/**
@@ -44,7 +44,7 @@ export class AccuracyTestingClient {
4444
try {
4545
const toolResultGeneratorFn = this.mockedTools[toolName];
4646
if (toolResultGeneratorFn) {
47-
return await toolResultGeneratorFn(args);
47+
return await toolResultGeneratorFn(args as Record<string, unknown>);
4848
}
4949

5050
return await tool.execute(args, options);
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { describe, beforeEach, it, afterEach, expect } from "vitest";
2+
import type { Collection } from "mongodb";
3+
import {
4+
databaseCollectionInvalidArgs,
5+
databaseCollectionParameters,
6+
defaultDriverOptions,
7+
defaultTestConfig,
8+
getDataFromUntrustedContent,
9+
getResponseContent,
10+
setupIntegrationTest,
11+
validateThrowsForInvalidArguments,
12+
validateToolMetadata,
13+
} from "../../../helpers.js";
14+
import { describeWithMongoDB, setupMongoDBIntegrationTest } from "../mongodbHelpers.js";
15+
import { createMockElicitInput } from "../../../../utils/elicitationMocks.js";
16+
import { Elicitation } from "../../../../../src/elicitation.js";
17+
18+
describeWithMongoDB("drop-index tool", (integration) => {
19+
let moviesCollection: Collection;
20+
let indexName: string;
21+
beforeEach(async () => {
22+
await integration.connectMcpClient();
23+
const client = integration.mongoClient();
24+
moviesCollection = client.db("mflix").collection("movies");
25+
await moviesCollection.insertMany([
26+
{
27+
name: "Movie1",
28+
year: 1994,
29+
},
30+
{
31+
name: "Movie2",
32+
year: 2001,
33+
},
34+
]);
35+
indexName = await moviesCollection.createIndex({ year: 1 });
36+
});
37+
38+
afterEach(async () => {
39+
await moviesCollection.drop();
40+
});
41+
42+
validateToolMetadata(integration, "drop-index", "Drop an index for the provided database and collection.", [
43+
...databaseCollectionParameters,
44+
{
45+
name: "indexName",
46+
type: "string",
47+
description: "The name of the index to be dropped.",
48+
required: true,
49+
},
50+
]);
51+
52+
validateThrowsForInvalidArguments(integration, "drop-index", [
53+
...databaseCollectionInvalidArgs,
54+
{ database: "test", collection: "testColl", indexName: null },
55+
{ database: "test", collection: "testColl", indexName: undefined },
56+
{ database: "test", collection: "testColl", indexName: [] },
57+
{ database: "test", collection: "testColl", indexName: true },
58+
{ database: "test", collection: "testColl", indexName: false },
59+
{ database: "test", collection: "testColl", indexName: 0 },
60+
{ database: "test", collection: "testColl", indexName: 12 },
61+
{ database: "test", collection: "testColl", indexName: "" },
62+
]);
63+
64+
describe.each([
65+
{
66+
database: "mflix",
67+
collection: "non-existent",
68+
},
69+
{
70+
database: "non-db",
71+
collection: "non-coll",
72+
},
73+
])(
74+
"when attempting to delete an index from non-existent namespace - $database $collection",
75+
({ database, collection }) => {
76+
it("should fail with error", async () => {
77+
const response = await integration.mcpClient().callTool({
78+
name: "drop-index",
79+
arguments: { database, collection, indexName: "non-existent" },
80+
});
81+
expect(response.isError).toBe(true);
82+
const content = getResponseContent(response.content);
83+
expect(content).toEqual(`Error running drop-index: ns not found ${database}.${collection}`);
84+
});
85+
}
86+
);
87+
88+
describe("when attempting to delete an index that does not exist", () => {
89+
it("should fail with error", async () => {
90+
const response = await integration.mcpClient().callTool({
91+
name: "drop-index",
92+
arguments: { database: "mflix", collection: "movies", indexName: "non-existent" },
93+
});
94+
expect(response.isError).toBe(true);
95+
const content = getResponseContent(response.content);
96+
expect(content).toEqual(`Error running drop-index: index not found with name [non-existent]`);
97+
});
98+
});
99+
100+
describe("when attempting to delete an index that exists", () => {
101+
it("should succeed", async () => {
102+
const response = await integration.mcpClient().callTool({
103+
name: "drop-index",
104+
// The index is created in beforeEach
105+
arguments: { database: "mflix", collection: "movies", indexName: indexName },
106+
});
107+
expect(response.isError).toBe(undefined);
108+
const content = getResponseContent(response.content);
109+
expect(content).toContain(`Successfully dropped the index from the provided namespace.`);
110+
const data = getDataFromUntrustedContent(content);
111+
expect(JSON.parse(data)).toMatchObject({ indexName, namespace: "mflix.movies" });
112+
});
113+
});
114+
});
115+
116+
describe("drop-index tool - when invoked via an elicitation enabled client", () => {
117+
const mockElicitInput = createMockElicitInput();
118+
const mdbIntegration = setupMongoDBIntegrationTest();
119+
const integration = setupIntegrationTest(
120+
() => defaultTestConfig,
121+
() => defaultDriverOptions,
122+
{ elicitInput: mockElicitInput }
123+
);
124+
let moviesCollection: Collection;
125+
let indexName: string;
126+
127+
beforeEach(async () => {
128+
moviesCollection = mdbIntegration.mongoClient().db("mflix").collection("movies");
129+
await moviesCollection.insertMany([
130+
{ name: "Movie1", year: 1994 },
131+
{ name: "Movie2", year: 2001 },
132+
]);
133+
indexName = await moviesCollection.createIndex({ year: 1 });
134+
await integration.mcpClient().callTool({
135+
name: "connect",
136+
arguments: {
137+
connectionString: mdbIntegration.connectionString(),
138+
},
139+
});
140+
});
141+
142+
afterEach(async () => {
143+
await moviesCollection.drop();
144+
});
145+
146+
it("should ask for confirmation before proceeding with tool call", async () => {
147+
expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2);
148+
mockElicitInput.confirmYes();
149+
await integration.mcpClient().callTool({
150+
name: "drop-index",
151+
arguments: { database: "mflix", collection: "movies", indexName },
152+
});
153+
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
154+
expect(mockElicitInput.mock).toHaveBeenCalledWith({
155+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
156+
message: expect.stringContaining(
157+
"You are about to drop the `year_1` index from the `mflix.movies` namespace"
158+
),
159+
requestedSchema: Elicitation.CONFIRMATION_SCHEMA,
160+
});
161+
expect(await moviesCollection.listIndexes().toArray()).toHaveLength(1);
162+
});
163+
164+
it("should not drop the index if the confirmation was not provided", async () => {
165+
expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2);
166+
mockElicitInput.confirmNo();
167+
await integration.mcpClient().callTool({
168+
name: "drop-index",
169+
arguments: { database: "mflix", collection: "movies", indexName },
170+
});
171+
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
172+
expect(mockElicitInput.mock).toHaveBeenCalledWith({
173+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
174+
message: expect.stringContaining(
175+
"You are about to drop the `year_1` index from the `mflix.movies` namespace"
176+
),
177+
requestedSchema: Elicitation.CONFIRMATION_SCHEMA,
178+
});
179+
expect(await moviesCollection.listIndexes().toArray()).toHaveLength(2);
180+
});
181+
});

tests/integration/transports/stdio.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describeWithMongoDB("StdioRunner", (integration) => {
3232
const response = await client.listTools();
3333
expect(response).toBeDefined();
3434
expect(response.tools).toBeDefined();
35-
expect(response.tools).toHaveLength(21);
35+
expect(response.tools).toHaveLength(22);
3636

3737
const sortedTools = response.tools.sort((a, b) => a.name.localeCompare(b.name));
3838
expect(sortedTools[0]?.name).toBe("aggregate");

0 commit comments

Comments
 (0)