Skip to content

chore: add remaining mongodb integration tests #105

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 24, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/tools/mongodb/metadata/explain.ts
Original file line number Diff line number Diff line change
@@ -47,14 +47,24 @@ export class ExplainTool extends MongoDBToolBase {
const method = methods[0];

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

let result: Document;
switch (method.name) {
case "aggregate": {
const { pipeline } = method.arguments;
result = await provider.aggregate(database, collection, pipeline).explain(ExplainTool.defaultVerbosity);
result = await provider
.aggregate(
database,
collection,
pipeline,
{},
{
writeConcern: undefined,
}
)
.explain(ExplainTool.defaultVerbosity);
break;
}
case "find": {
@@ -66,18 +76,21 @@ export class ExplainTool extends MongoDBToolBase {
}
case "count": {
const { query } = method.arguments;
// This helper doesn't have explain() command but does have the argument explain
result = (await provider.count(database, collection, query, {
explain: ExplainTool.defaultVerbosity,
})) as unknown as Document;
result = await provider.mongoClient.db(database).command({
explain: {
count: collection,
query,
},
verbosity: ExplainTool.defaultVerbosity,
});
break;
}
}

return {
content: [
{
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.`,
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.`,
type: "text",
},
{
37 changes: 33 additions & 4 deletions src/tools/mongodb/update/renameCollection.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { MongoDBToolBase } from "../mongodbTool.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";

export class RenameCollectionTool extends MongoDBToolBase {
protected name = "rename-collection";
protected description = "Renames a collection in a MongoDB database";
protected argsShape = {
collection: z.string().describe("Collection name"),
database: z.string().describe("Database name"),
...DbOperationArgs,
newName: z.string().describe("The new name for the collection"),
dropTarget: z.boolean().optional().default(false).describe("If true, drops the target collection if it exists"),
};
@@ -28,10 +27,40 @@ export class RenameCollectionTool extends MongoDBToolBase {
return {
content: [
{
text: `Collection \`${collection}\` renamed to \`${result.collectionName}\` in database \`${database}\`.`,
text: `Collection "${collection}" renamed to "${result.collectionName}" in database "${database}".`,
type: "text",
},
],
};
}

protected handleError(
error: unknown,
args: ToolArgs<typeof this.argsShape>
): Promise<CallToolResult> | CallToolResult {
if (error instanceof Error && "codeName" in error) {
switch (error.codeName) {
case "NamespaceNotFound":
return {
content: [
{
text: `Cannot rename "${args.database}.${args.collection}" because it doesn't exist.`,
type: "text",
},
],
};
case "NamespaceExists":
return {
content: [
{
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.`,
type: "text",
},
],
};
}
}

return super.handleError(error, args);
}
}
12 changes: 5 additions & 7 deletions src/tools/mongodb/update/updateMany.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { MongoDBToolBase } from "../mongodbTool.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";

export class UpdateManyTool extends MongoDBToolBase {
protected name = "update-many";
protected description = "Updates all documents that match the specified filter for a collection";
protected argsShape = {
collection: z.string().describe("Collection name"),
database: z.string().describe("Database name"),
...DbOperationArgs,
filter: z
.object({})
.passthrough()
@@ -19,7 +18,6 @@ export class UpdateManyTool extends MongoDBToolBase {
update: z
.object({})
.passthrough()
.optional()
.describe("An update document describing the modifications to apply using update operator expressions"),
upsert: z
.boolean()
@@ -41,15 +39,15 @@ export class UpdateManyTool extends MongoDBToolBase {
});

let message = "";
if (result.matchedCount === 0) {
message = `No documents matched the filter.`;
if (result.matchedCount === 0 && result.modifiedCount === 0 && result.upsertedCount === 0) {
message = "No documents matched the filter.";
} else {
message = `Matched ${result.matchedCount} document(s).`;
if (result.modifiedCount > 0) {
message += ` Modified ${result.modifiedCount} document(s).`;
}
if (result.upsertedCount > 0) {
message += ` Upserted ${result.upsertedCount} document(s) (with id: ${result.upsertedId?.toString()}).`;
message += ` Upserted ${result.upsertedCount} document with id: ${result.upsertedId?.toString()}.`;
}
}

172 changes: 172 additions & 0 deletions tests/integration/tools/mongodb/metadata/explain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {
databaseCollectionParameters,
setupIntegrationTest,
validateToolMetadata,
validateThrowsForInvalidArguments,
getResponseElements,
} from "../../../helpers.js";
import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js";

describeWithMongoDB("explain tool", (integration) => {
validateToolMetadata(
integration,
"explain",
"Returns statistics describing the execution of the winning plan chosen by the query optimizer for the evaluated method",
[
...databaseCollectionParameters,

{
name: "method",
description: "The method and its arguments to run",
type: "array",
required: true,
},
]
);

validateThrowsForInvalidArguments(integration, "explain", [
{},
{ database: 123, collection: "bar", method: [{ name: "find", arguments: {} }] },
{ database: "test", collection: true, method: [{ name: "find", arguments: {} }] },
{ database: "test", collection: "bar", method: [{ name: "dnif", arguments: {} }] },
{ database: "test", collection: "bar", method: "find" },
{ database: "test", collection: "bar", method: { name: "find", arguments: {} } },
]);

const testCases = [
{
method: "aggregate",
arguments: { pipeline: [{ $match: { name: "Peter" } }] },
},
{
method: "find",
arguments: { filter: { name: "Peter" } },
},
{
method: "count",
arguments: {
query: { name: "Peter" },
},
},
];

for (const testType of ["database", "collection"] as const) {
describe(`with non-existing ${testType}`, () => {
for (const testCase of testCases) {
it(`should return the explain plan for ${testCase.method}`, async () => {
if (testType === "database") {
const { databases } = await integration.mongoClient().db("").admin().listDatabases();
expect(databases.find((db) => db.name === integration.randomDbName())).toBeUndefined();
} else if (testType === "collection") {
await integration
.mongoClient()
.db(integration.randomDbName())
.createCollection("some-collection");

const collections = await integration
.mongoClient()
.db(integration.randomDbName())
.listCollections()
.toArray();

expect(collections.find((collection) => collection.name === "coll1")).toBeUndefined();
}

await integration.connectMcpClient();

const response = await integration.mcpClient().callTool({
name: "explain",
arguments: {
database: integration.randomDbName(),
collection: "coll1",
method: [
{
name: testCase.method,
arguments: testCase.arguments,
},
],
},
});

const content = getResponseElements(response.content);
expect(content).toHaveLength(2);
expect(content[0].text).toEqual(
`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.`
);

expect(content[1].text).toContain("queryPlanner");
expect(content[1].text).toContain("winningPlan");
});
}
});
}

describe("with existing database and collection", () => {
for (const indexed of [true, false] as const) {
describe(`with ${indexed ? "an index" : "no index"}`, () => {
beforeEach(async () => {
await integration
.mongoClient()
.db(integration.randomDbName())
.collection("people")
.insertMany([{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }]);

if (indexed) {
await integration
.mongoClient()
.db(integration.randomDbName())
.collection("people")
.createIndex({ name: 1 });
}
});

for (const testCase of testCases) {
it(`should return the explain plan for ${testCase.method}`, async () => {
await integration.connectMcpClient();

const response = await integration.mcpClient().callTool({
name: "explain",
arguments: {
database: integration.randomDbName(),
collection: "people",
method: [
{
name: testCase.method,
arguments: testCase.arguments,
},
],
},
});

const content = getResponseElements(response.content);
expect(content).toHaveLength(2);
expect(content[0].text).toEqual(
`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.`
);

expect(content[1].text).toContain("queryPlanner");
expect(content[1].text).toContain("winningPlan");

if (indexed) {
if (testCase.method === "count") {
expect(content[1].text).toContain("COUNT_SCAN");
} else {
expect(content[1].text).toContain("IXSCAN");
}
expect(content[1].text).toContain("name_1");
} else {
expect(content[1].text).toContain("COLLSCAN");
}
});
}
});
}
});

validateAutoConnectBehavior(integration, "explain", () => {
return {
args: { database: integration.randomDbName(), collection: "coll1", method: [] },
expectedResponse: "No method provided. Expected one of the following: `aggregate`, `find`, or `count`",
};
});
});
Loading