Skip to content

Commit 0584f38

Browse files
authored
chore: add remaining mongodb integration tests (#105)
1 parent ab1e3fa commit 0584f38

File tree

6 files changed

+665
-18
lines changed

6 files changed

+665
-18
lines changed

src/tools/mongodb/metadata/explain.ts

+20-7
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,24 @@ export class ExplainTool extends MongoDBToolBase {
4747
const method = methods[0];
4848

4949
if (!method) {
50-
throw new Error("No method provided");
50+
throw new Error("No method provided. Expected one of the following: `aggregate`, `find`, or `count`");
5151
}
5252

5353
let result: Document;
5454
switch (method.name) {
5555
case "aggregate": {
5656
const { pipeline } = method.arguments;
57-
result = await provider.aggregate(database, collection, pipeline).explain(ExplainTool.defaultVerbosity);
57+
result = await provider
58+
.aggregate(
59+
database,
60+
collection,
61+
pipeline,
62+
{},
63+
{
64+
writeConcern: undefined,
65+
}
66+
)
67+
.explain(ExplainTool.defaultVerbosity);
5868
break;
5969
}
6070
case "find": {
@@ -66,18 +76,21 @@ export class ExplainTool extends MongoDBToolBase {
6676
}
6777
case "count": {
6878
const { query } = method.arguments;
69-
// This helper doesn't have explain() command but does have the argument explain
70-
result = (await provider.count(database, collection, query, {
71-
explain: ExplainTool.defaultVerbosity,
72-
})) as unknown as Document;
79+
result = await provider.mongoClient.db(database).command({
80+
explain: {
81+
count: collection,
82+
query,
83+
},
84+
verbosity: ExplainTool.defaultVerbosity,
85+
});
7386
break;
7487
}
7588
}
7689

7790
return {
7891
content: [
7992
{
80-
text: `Here is some information about the winning plan chosen by the query optimizer for running the given \`${method.name}\` operation in \`${database}.${collection}\`. This information can be used to understand how the query was executed and to optimize the query performance.`,
93+
text: `Here is some information about the winning plan chosen by the query optimizer for running the given \`${method.name}\` operation in "${database}.${collection}". This information can be used to understand how the query was executed and to optimize the query performance.`,
8194
type: "text",
8295
},
8396
{

src/tools/mongodb/update/renameCollection.ts

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { z } from "zod";
22
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3-
import { MongoDBToolBase } from "../mongodbTool.js";
3+
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55

66
export class RenameCollectionTool extends MongoDBToolBase {
77
protected name = "rename-collection";
88
protected description = "Renames a collection in a MongoDB database";
99
protected argsShape = {
10-
collection: z.string().describe("Collection name"),
11-
database: z.string().describe("Database name"),
10+
...DbOperationArgs,
1211
newName: z.string().describe("The new name for the collection"),
1312
dropTarget: z.boolean().optional().default(false).describe("If true, drops the target collection if it exists"),
1413
};
@@ -28,10 +27,40 @@ export class RenameCollectionTool extends MongoDBToolBase {
2827
return {
2928
content: [
3029
{
31-
text: `Collection \`${collection}\` renamed to \`${result.collectionName}\` in database \`${database}\`.`,
30+
text: `Collection "${collection}" renamed to "${result.collectionName}" in database "${database}".`,
3231
type: "text",
3332
},
3433
],
3534
};
3635
}
36+
37+
protected handleError(
38+
error: unknown,
39+
args: ToolArgs<typeof this.argsShape>
40+
): Promise<CallToolResult> | CallToolResult {
41+
if (error instanceof Error && "codeName" in error) {
42+
switch (error.codeName) {
43+
case "NamespaceNotFound":
44+
return {
45+
content: [
46+
{
47+
text: `Cannot rename "${args.database}.${args.collection}" because it doesn't exist.`,
48+
type: "text",
49+
},
50+
],
51+
};
52+
case "NamespaceExists":
53+
return {
54+
content: [
55+
{
56+
text: `Cannot rename "${args.database}.${args.collection}" to "${args.newName}" because the target collection already exists. If you want to overwrite it, set the "dropTarget" argument to true.`,
57+
type: "text",
58+
},
59+
],
60+
};
61+
}
62+
}
63+
64+
return super.handleError(error, args);
65+
}
3766
}

src/tools/mongodb/update/updateMany.ts

+5-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { z } from "zod";
22
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3-
import { MongoDBToolBase } from "../mongodbTool.js";
3+
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55

66
export class UpdateManyTool extends MongoDBToolBase {
77
protected name = "update-many";
88
protected description = "Updates all documents that match the specified filter for a collection";
99
protected argsShape = {
10-
collection: z.string().describe("Collection name"),
11-
database: z.string().describe("Database name"),
10+
...DbOperationArgs,
1211
filter: z
1312
.object({})
1413
.passthrough()
@@ -19,7 +18,6 @@ export class UpdateManyTool extends MongoDBToolBase {
1918
update: z
2019
.object({})
2120
.passthrough()
22-
.optional()
2321
.describe("An update document describing the modifications to apply using update operator expressions"),
2422
upsert: z
2523
.boolean()
@@ -41,15 +39,15 @@ export class UpdateManyTool extends MongoDBToolBase {
4139
});
4240

4341
let message = "";
44-
if (result.matchedCount === 0) {
45-
message = `No documents matched the filter.`;
42+
if (result.matchedCount === 0 && result.modifiedCount === 0 && result.upsertedCount === 0) {
43+
message = "No documents matched the filter.";
4644
} else {
4745
message = `Matched ${result.matchedCount} document(s).`;
4846
if (result.modifiedCount > 0) {
4947
message += ` Modified ${result.modifiedCount} document(s).`;
5048
}
5149
if (result.upsertedCount > 0) {
52-
message += ` Upserted ${result.upsertedCount} document(s) (with id: ${result.upsertedId?.toString()}).`;
50+
message += ` Upserted ${result.upsertedCount} document with id: ${result.upsertedId?.toString()}.`;
5351
}
5452
}
5553

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
databaseCollectionParameters,
3+
setupIntegrationTest,
4+
validateToolMetadata,
5+
validateThrowsForInvalidArguments,
6+
getResponseElements,
7+
} from "../../../helpers.js";
8+
import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js";
9+
10+
describeWithMongoDB("explain tool", (integration) => {
11+
validateToolMetadata(
12+
integration,
13+
"explain",
14+
"Returns statistics describing the execution of the winning plan chosen by the query optimizer for the evaluated method",
15+
[
16+
...databaseCollectionParameters,
17+
18+
{
19+
name: "method",
20+
description: "The method and its arguments to run",
21+
type: "array",
22+
required: true,
23+
},
24+
]
25+
);
26+
27+
validateThrowsForInvalidArguments(integration, "explain", [
28+
{},
29+
{ database: 123, collection: "bar", method: [{ name: "find", arguments: {} }] },
30+
{ database: "test", collection: true, method: [{ name: "find", arguments: {} }] },
31+
{ database: "test", collection: "bar", method: [{ name: "dnif", arguments: {} }] },
32+
{ database: "test", collection: "bar", method: "find" },
33+
{ database: "test", collection: "bar", method: { name: "find", arguments: {} } },
34+
]);
35+
36+
const testCases = [
37+
{
38+
method: "aggregate",
39+
arguments: { pipeline: [{ $match: { name: "Peter" } }] },
40+
},
41+
{
42+
method: "find",
43+
arguments: { filter: { name: "Peter" } },
44+
},
45+
{
46+
method: "count",
47+
arguments: {
48+
query: { name: "Peter" },
49+
},
50+
},
51+
];
52+
53+
for (const testType of ["database", "collection"] as const) {
54+
describe(`with non-existing ${testType}`, () => {
55+
for (const testCase of testCases) {
56+
it(`should return the explain plan for ${testCase.method}`, async () => {
57+
if (testType === "database") {
58+
const { databases } = await integration.mongoClient().db("").admin().listDatabases();
59+
expect(databases.find((db) => db.name === integration.randomDbName())).toBeUndefined();
60+
} else if (testType === "collection") {
61+
await integration
62+
.mongoClient()
63+
.db(integration.randomDbName())
64+
.createCollection("some-collection");
65+
66+
const collections = await integration
67+
.mongoClient()
68+
.db(integration.randomDbName())
69+
.listCollections()
70+
.toArray();
71+
72+
expect(collections.find((collection) => collection.name === "coll1")).toBeUndefined();
73+
}
74+
75+
await integration.connectMcpClient();
76+
77+
const response = await integration.mcpClient().callTool({
78+
name: "explain",
79+
arguments: {
80+
database: integration.randomDbName(),
81+
collection: "coll1",
82+
method: [
83+
{
84+
name: testCase.method,
85+
arguments: testCase.arguments,
86+
},
87+
],
88+
},
89+
});
90+
91+
const content = getResponseElements(response.content);
92+
expect(content).toHaveLength(2);
93+
expect(content[0].text).toEqual(
94+
`Here is some information about the winning plan chosen by the query optimizer for running the given \`${testCase.method}\` operation in "${integration.randomDbName()}.coll1". This information can be used to understand how the query was executed and to optimize the query performance.`
95+
);
96+
97+
expect(content[1].text).toContain("queryPlanner");
98+
expect(content[1].text).toContain("winningPlan");
99+
});
100+
}
101+
});
102+
}
103+
104+
describe("with existing database and collection", () => {
105+
for (const indexed of [true, false] as const) {
106+
describe(`with ${indexed ? "an index" : "no index"}`, () => {
107+
beforeEach(async () => {
108+
await integration
109+
.mongoClient()
110+
.db(integration.randomDbName())
111+
.collection("people")
112+
.insertMany([{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }]);
113+
114+
if (indexed) {
115+
await integration
116+
.mongoClient()
117+
.db(integration.randomDbName())
118+
.collection("people")
119+
.createIndex({ name: 1 });
120+
}
121+
});
122+
123+
for (const testCase of testCases) {
124+
it(`should return the explain plan for ${testCase.method}`, async () => {
125+
await integration.connectMcpClient();
126+
127+
const response = await integration.mcpClient().callTool({
128+
name: "explain",
129+
arguments: {
130+
database: integration.randomDbName(),
131+
collection: "people",
132+
method: [
133+
{
134+
name: testCase.method,
135+
arguments: testCase.arguments,
136+
},
137+
],
138+
},
139+
});
140+
141+
const content = getResponseElements(response.content);
142+
expect(content).toHaveLength(2);
143+
expect(content[0].text).toEqual(
144+
`Here is some information about the winning plan chosen by the query optimizer for running the given \`${testCase.method}\` operation in "${integration.randomDbName()}.people". This information can be used to understand how the query was executed and to optimize the query performance.`
145+
);
146+
147+
expect(content[1].text).toContain("queryPlanner");
148+
expect(content[1].text).toContain("winningPlan");
149+
150+
if (indexed) {
151+
if (testCase.method === "count") {
152+
expect(content[1].text).toContain("COUNT_SCAN");
153+
} else {
154+
expect(content[1].text).toContain("IXSCAN");
155+
}
156+
expect(content[1].text).toContain("name_1");
157+
} else {
158+
expect(content[1].text).toContain("COLLSCAN");
159+
}
160+
});
161+
}
162+
});
163+
}
164+
});
165+
166+
validateAutoConnectBehavior(integration, "explain", () => {
167+
return {
168+
args: { database: integration.randomDbName(), collection: "coll1", method: [] },
169+
expectedResponse: "No method provided. Expected one of the following: `aggregate`, `find`, or `count`",
170+
};
171+
});
172+
});

0 commit comments

Comments
 (0)