Skip to content

Commit 766dd57

Browse files
chore: addresses a few common concerns
1. Wraps the toolcall result in untrusted data wrapper 2. Add additional test for confirming that we don't drop when confirmation is not provided
1 parent bda9133 commit 766dd57

File tree

3 files changed

+149
-61
lines changed

3 files changed

+149
-61
lines changed

src/tools/mongodb/delete/dropSearchIndex.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
import { CommonArgs } from "../../args.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
4-
import { type OperationType, type ToolArgs } from "../../tool.js";
4+
import { formatUntrustedData, type OperationType, type ToolArgs } from "../../tool.js";
55
import { ListSearchIndexesTool } from "../search/listSearchIndexes.js";
66

77
export class DropSearchIndexTool extends MongoDBToolBase {
@@ -30,24 +30,23 @@ export class DropSearchIndexTool extends MongoDBToolBase {
3030
const indexDoesNotExist = !searchIndexes.find((index) => index.name === indexName);
3131
if (indexDoesNotExist) {
3232
return {
33-
content: [
34-
{
35-
type: "text",
36-
text: `Index with name "${indexName}" does not exist in the provided namespace "${database}.${collection}".`,
37-
},
38-
],
33+
content: formatUntrustedData(
34+
"Index does not exist in the provided namespace.",
35+
JSON.stringify({ indexName, namespace: `${database}.${collection}` })
36+
),
3937
isError: true,
4038
};
4139
}
4240

4341
await provider.dropSearchIndex(database, collection, indexName);
4442
return {
45-
content: [
46-
{
47-
type: "text",
48-
text: `Successfully dropped the index with name "${indexName}" from the provided namespace "${database}.${collection}".`,
49-
},
50-
],
43+
content: formatUntrustedData(
44+
"Successfully dropped the index from the provided namespace.",
45+
JSON.stringify({
46+
indexName,
47+
namespace: `${database}.${collection}`,
48+
})
49+
),
5150
};
5251
}
5352

tests/integration/helpers.ts

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -393,57 +393,90 @@ export function sleep(ms: number): Promise<void> {
393393
return new Promise((resolve) => setTimeout(resolve, ms));
394394
}
395395

396-
export async function waitUntilSearchManagementServiceIsReady(
397-
collection: Collection,
398-
abortSignal?: AbortSignal
396+
export async function waitFor(
397+
condition: () => boolean | Promise<boolean>,
398+
{
399+
retries,
400+
retryTimeout,
401+
abortSignal,
402+
shouldRetryOnError,
403+
}: {
404+
retries: number;
405+
retryTimeout: number;
406+
abortSignal?: AbortSignal;
407+
shouldRetryOnError?: (error: unknown) => boolean | Promise<boolean>;
408+
} = {
409+
retries: 100,
410+
retryTimeout: 100,
411+
}
399412
): Promise<void> {
400-
for (let i = 0; i < SEARCH_READY_CHECK_RETRIES && !abortSignal?.aborted; i++) {
413+
for (let i = 0; i < retries && !abortSignal?.aborted; i++) {
401414
try {
402-
await collection.listSearchIndexes({}).toArray();
403-
return;
415+
if (await condition()) {
416+
return;
417+
}
418+
419+
await sleep(retryTimeout);
404420
} catch (error) {
405-
if (
406-
error instanceof Error &&
407-
error.message.includes("Error connecting to Search Index Management service")
408-
) {
409-
await sleep(100);
421+
if (shouldRetryOnError && (await shouldRetryOnError(error))) {
422+
await sleep(retryTimeout);
410423
continue;
411-
} else {
412-
throw error;
413424
}
425+
throw error;
414426
}
415427
}
416428
}
417429

430+
export async function waitUntilSearchManagementServiceIsReady(
431+
collection: Collection,
432+
abortSignal?: AbortSignal
433+
): Promise<void> {
434+
await waitFor(
435+
async (): Promise<boolean> => {
436+
await collection.listSearchIndexes({}).toArray();
437+
return true;
438+
},
439+
{
440+
retries: SEARCH_READY_CHECK_RETRIES,
441+
retryTimeout: 100,
442+
abortSignal,
443+
shouldRetryOnError: (error: unknown) => {
444+
return (
445+
error instanceof Error &&
446+
error.message.includes("Error connecting to Search Index Management service")
447+
);
448+
},
449+
}
450+
);
451+
}
452+
418453
async function waitUntilSearchIndexIs(
419454
collection: Collection,
420455
searchIndex: string,
421456
indexValidator: (index: { name: string; queryable: boolean }) => boolean,
422457
abortSignal?: AbortSignal
423458
): Promise<void> {
424-
for (let i = 0; i < SEARCH_INDEX_STATUS_CHECK_RETRIES && !abortSignal?.aborted; i++) {
425-
try {
459+
await waitFor(
460+
async (): Promise<boolean> => {
426461
const searchIndexes = (await collection.listSearchIndexes(searchIndex).toArray()) as {
427462
name: string;
428463
queryable: boolean;
429464
}[];
430465

431-
if (searchIndexes.some((index) => indexValidator(index))) {
432-
return;
433-
}
434-
await sleep(100);
435-
} catch (error) {
436-
if (
437-
error instanceof Error &&
438-
error.message.includes("Error connecting to Search Index Management service")
439-
) {
440-
await sleep(100);
441-
continue;
442-
} else {
443-
throw error;
444-
}
466+
return searchIndexes.some((index) => indexValidator(index));
467+
},
468+
{
469+
retries: SEARCH_INDEX_STATUS_CHECK_RETRIES,
470+
retryTimeout: 100,
471+
abortSignal,
472+
shouldRetryOnError: (error: unknown) => {
473+
return (
474+
error instanceof Error &&
475+
error.message.includes("Error connecting to Search Index Management service")
476+
);
477+
},
445478
}
446-
}
479+
);
447480
}
448481

449482
export async function waitUntilSearchIndexIsListed(

tests/integration/tools/mongodb/delete/dropSearchIndex.test.ts

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, afterEach, describe, expect, it } from "vitest";
1+
import { beforeEach, afterEach, describe, expect, it, vi, type MockInstance } from "vitest";
22
import {
33
databaseCollectionInvalidArgs,
44
databaseCollectionParameters,
@@ -10,8 +10,9 @@ import {
1010
validateToolMetadata,
1111
waitUntilSearchManagementServiceIsReady,
1212
waitUntilSearchIndexIsListed,
13+
getDataFromUntrustedContent,
1314
} from "../../../helpers.js";
14-
import { describeWithMongoDB } from "../mongodbHelpers.js";
15+
import { describeWithMongoDB, setupMongoDBIntegrationTest } from "../mongodbHelpers.js";
1516
import type { Collection } from "mongodb";
1617
import { createMockElicitInput } from "../../../../utils/elicitationMocks.js";
1718
import { Elicitation } from "../../../../../src/elicitation.js";
@@ -77,9 +78,10 @@ describeWithMongoDB(
7778
});
7879
expect(response.isError).toBe(true);
7980
const content = getResponseContent(response.content);
80-
expect(content).toEqual(
81-
'Index with name "non-existent" does not exist in the provided namespace "any.foo".'
82-
);
81+
expect(content).toContain("Index does not exist in the provided namespace.");
82+
83+
const data = getDataFromUntrustedContent(content);
84+
expect(JSON.parse(data)).toMatchObject({ indexName: "non-existent", namespace: "any.foo" });
8385
});
8486
});
8587

@@ -91,7 +93,7 @@ describeWithMongoDB(
9193
await moviesCollection.insertMany([
9294
{
9395
name: "Movie1",
94-
plot: "This is a horrible movie about a database called BongoDB and how it tried to copy the original MangoDB.",
96+
plot: "This is a horrible movie about a database called BongoDB and how it tried to copy the OG MangoDB.",
9597
},
9698
]);
9799
await waitUntilSearchManagementServiceIsReady(moviesCollection, signal);
@@ -103,13 +105,7 @@ describeWithMongoDB(
103105
});
104106

105107
afterEach(async () => {
106-
try {
107-
await moviesCollection.dropSearchIndex("searchIdx");
108-
} catch (error) {
109-
if (error instanceof Error && !error.message.includes("not found in")) {
110-
throw error;
111-
}
112-
}
108+
// dropping collection also drops the associated search indexes
113109
await moviesCollection.drop();
114110
});
115111

@@ -119,9 +115,10 @@ describeWithMongoDB(
119115
arguments: { database: "mflix", collection: "movies", indexName: "searchIdx" },
120116
});
121117
const content = getResponseContent(response.content);
122-
expect(content).toEqual(
123-
'Successfully dropped the index with name "searchIdx" from the provided namespace "mflix.movies".'
124-
);
118+
expect(content).toContain("Successfully dropped the index from the provided namespace.");
119+
120+
const data = getDataFromUntrustedContent(content);
121+
expect(JSON.parse(data)).toMatchObject({ indexName: "searchIdx", namespace: "mflix.movies" });
125122
});
126123
});
127124
},
@@ -132,23 +129,82 @@ describeWithMongoDB(
132129

133130
describe("drop-search-index tool - when invoked via an elicitation enabled client", () => {
134131
const mockElicitInput = createMockElicitInput();
132+
const mdbIntegration = setupMongoDBIntegrationTest({ search: true });
135133
const integration = setupIntegrationTest(
136134
() => defaultTestConfig,
137135
() => defaultDriverOptions,
138136
{ elicitInput: mockElicitInput }
139137
);
140138

139+
let moviesCollection: Collection;
140+
let dropSearchIndexSpy: MockInstance;
141+
142+
beforeEach(async ({ signal }) => {
143+
const mongoClient = mdbIntegration.mongoClient();
144+
moviesCollection = mongoClient.db("mflix").collection("movies");
145+
await moviesCollection.insertMany([
146+
{
147+
name: "Movie1",
148+
plot: "This is a horrible movie about a database called BongoDB and how it tried to copy the OG MangoDB.",
149+
},
150+
]);
151+
await waitUntilSearchManagementServiceIsReady(moviesCollection, signal);
152+
await moviesCollection.createSearchIndex({
153+
name: "searchIdx",
154+
definition: { mappings: { dynamic: true } },
155+
});
156+
await waitUntilSearchIndexIsListed(moviesCollection, "searchIdx", signal);
157+
158+
await integration.mcpClient().callTool({
159+
name: "connect",
160+
arguments: {
161+
connectionString: mdbIntegration.connectionString(),
162+
},
163+
});
164+
165+
// Note: Unlike drop-index tool test, we don't test the final state of
166+
// indexes because of possible longer wait periods for changes to
167+
// reflect, at-times taking >30 seconds.
168+
dropSearchIndexSpy = vi.spyOn(integration.mcpServer().session.serviceProvider, "dropSearchIndex");
169+
});
170+
171+
afterEach(async () => {
172+
// dropping collection also drops the associated search indexes
173+
await moviesCollection.drop();
174+
});
175+
141176
it("should ask for confirmation before proceeding with tool call", async () => {
142177
mockElicitInput.confirmYes();
143178
await integration.mcpClient().callTool({
144179
name: "drop-search-index",
145-
arguments: { database: "any", collection: "foo", indexName: "default" },
180+
arguments: { database: "mflix", collection: "movies", indexName: "searchIdx" },
181+
});
182+
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
183+
expect(mockElicitInput.mock).toHaveBeenCalledWith({
184+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
185+
message: expect.stringContaining(
186+
"You are about to drop the `searchIdx` index from the `mflix.movies` namespace"
187+
),
188+
requestedSchema: Elicitation.CONFIRMATION_SCHEMA,
189+
});
190+
191+
expect(dropSearchIndexSpy).toHaveBeenCalledExactlyOnceWith("mflix", "movies", "searchIdx");
192+
});
193+
194+
it("should not drop the index if the confirmation was not provided", async () => {
195+
mockElicitInput.confirmNo();
196+
await integration.mcpClient().callTool({
197+
name: "drop-search-index",
198+
arguments: { database: "mflix", collection: "movies", indexName: "searchIdx" },
146199
});
147200
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
148201
expect(mockElicitInput.mock).toHaveBeenCalledWith({
149202
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
150-
message: expect.stringContaining("You are about to drop the `default` index from the `any.foo` namespace"),
203+
message: expect.stringContaining(
204+
"You are about to drop the `searchIdx` index from the `mflix.movies` namespace"
205+
),
151206
requestedSchema: Elicitation.CONFIRMATION_SCHEMA,
152207
});
208+
expect(dropSearchIndexSpy).not.toHaveBeenCalled();
153209
});
154210
});

0 commit comments

Comments
 (0)