diff --git a/src/tools/mongodb/metadata/explain.ts b/src/tools/mongodb/metadata/explain.ts index a71045b8..e529e899 100644 --- a/src/tools/mongodb/metadata/explain.ts +++ b/src/tools/mongodb/metadata/explain.ts @@ -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,10 +76,13 @@ 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; } } @@ -77,7 +90,7 @@ export class ExplainTool extends MongoDBToolBase { 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", }, { diff --git a/src/tools/mongodb/update/renameCollection.ts b/src/tools/mongodb/update/renameCollection.ts index d513fef4..d3b07c15 100644 --- a/src/tools/mongodb/update/renameCollection.ts +++ b/src/tools/mongodb/update/renameCollection.ts @@ -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 + ): Promise | 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); + } } diff --git a/src/tools/mongodb/update/updateMany.ts b/src/tools/mongodb/update/updateMany.ts index 4924f130..c11d8a49 100644 --- a/src/tools/mongodb/update/updateMany.ts +++ b/src/tools/mongodb/update/updateMany.ts @@ -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()}.`; } } diff --git a/tests/integration/tools/mongodb/metadata/explain.test.ts b/tests/integration/tools/mongodb/metadata/explain.test.ts new file mode 100644 index 00000000..dafdd238 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/explain.test.ts @@ -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`", + }; + }); +}); diff --git a/tests/integration/tools/mongodb/update/renameCollection.test.ts b/tests/integration/tools/mongodb/update/renameCollection.test.ts new file mode 100644 index 00000000..1c904458 --- /dev/null +++ b/tests/integration/tools/mongodb/update/renameCollection.test.ts @@ -0,0 +1,196 @@ +import { + getResponseContent, + databaseCollectionParameters, + setupIntegrationTest, + validateToolMetadata, + validateThrowsForInvalidArguments, +} from "../../../helpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +describeWithMongoDB("renameCollection tool", (integration) => { + validateToolMetadata(integration, "rename-collection", "Renames a collection in a MongoDB database", [ + ...databaseCollectionParameters, + + { + name: "newName", + description: "The new name for the collection", + type: "string", + required: true, + }, + { + name: "dropTarget", + description: "If true, drops the target collection if it exists", + type: "boolean", + required: false, + }, + ]); + + validateThrowsForInvalidArguments(integration, "rename-collection", [ + {}, + { database: 123, collection: "bar" }, + { database: "test", collection: "bar", newName: "foo", extra: "extra" }, + { database: "test", collection: [], newName: "foo" }, + { database: "test", collection: "bar", newName: 10 }, + { database: "test", collection: "bar", newName: "foo", dropTarget: "true" }, + { database: "test", collection: "bar", newName: "foo", dropTarget: 1 }, + ]); + + describe("with non-existing database", () => { + it("returns an error", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "rename-collection", + arguments: { database: "non-existent", collection: "foos", newName: "bar" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual(`Cannot rename "non-existent.foos" because it doesn't exist.`); + }); + }); + + describe("with non-existing collection", () => { + it("returns an error", async () => { + await integration.mongoClient().db(integration.randomDbName()).collection("bar").insertOne({}); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "rename-collection", + arguments: { database: integration.randomDbName(), collection: "non-existent", newName: "foo" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Cannot rename "${integration.randomDbName()}.non-existent" because it doesn't exist.` + ); + }); + }); + + describe("with existing collection", () => { + it("renames to non-existing collection", async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .insertOne({ value: 42 }); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "rename-collection", + arguments: { database: integration.randomDbName(), collection: "before", newName: "after" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Collection "before" renamed to "after" in database "${integration.randomDbName()}".` + ); + + const docsInBefore = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .find({}) + .toArray(); + expect(docsInBefore).toHaveLength(0); + + const docsInAfter = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("after") + .find({}) + .toArray(); + expect(docsInAfter).toHaveLength(1); + expect(docsInAfter[0].value).toEqual(42); + }); + + it("returns an error when renaming to an existing collection", async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .insertOne({ value: 42 }); + await integration.mongoClient().db(integration.randomDbName()).collection("after").insertOne({ value: 84 }); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "rename-collection", + arguments: { database: integration.randomDbName(), collection: "before", newName: "after" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Cannot rename "${integration.randomDbName()}.before" to "after" because the target collection already exists. If you want to overwrite it, set the "dropTarget" argument to true.` + ); + + // Ensure no data was lost + const docsInBefore = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .find({}) + .toArray(); + expect(docsInBefore).toHaveLength(1); + expect(docsInBefore[0].value).toEqual(42); + + const docsInAfter = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("after") + .find({}) + .toArray(); + expect(docsInAfter).toHaveLength(1); + expect(docsInAfter[0].value).toEqual(84); + }); + + it("renames to existing collection with dropTarget", async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .insertOne({ value: 42 }); + await integration.mongoClient().db(integration.randomDbName()).collection("after").insertOne({ value: 84 }); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "rename-collection", + arguments: { + database: integration.randomDbName(), + collection: "before", + newName: "after", + dropTarget: true, + }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Collection "before" renamed to "after" in database "${integration.randomDbName()}".` + ); + + // Ensure the data was moved + const docsInBefore = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("before") + .find({}) + .toArray(); + expect(docsInBefore).toHaveLength(0); + + const docsInAfter = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("after") + .find({}) + .toArray(); + expect(docsInAfter).toHaveLength(1); + expect(docsInAfter[0].value).toEqual(42); + }); + }); + + validateAutoConnectBehavior( + integration, + "rename-collection", + () => { + return { + args: { database: integration.randomDbName(), collection: "coll1", newName: "coll2" }, + expectedResponse: `Collection "coll1" renamed to "coll2" in database "${integration.randomDbName()}".`, + }; + }, + async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + } + ); +}); diff --git a/tests/integration/tools/mongodb/update/updateMany.test.ts b/tests/integration/tools/mongodb/update/updateMany.test.ts new file mode 100644 index 00000000..6a05f640 --- /dev/null +++ b/tests/integration/tools/mongodb/update/updateMany.test.ts @@ -0,0 +1,239 @@ +import { + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseContent, +} from "../../../helpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +describeWithMongoDB("updateMany tool", (integration) => { + validateToolMetadata( + integration, + "update-many", + "Updates all documents that match the specified filter for a collection", + [ + ...databaseCollectionParameters, + + { + name: "filter", + description: + "The selection criteria for the update, matching the syntax of the filter argument of db.collection.updateOne()", + type: "object", + required: false, + }, + { + name: "update", + description: + "An update document describing the modifications to apply using update operator expressions", + type: "object", + required: true, + }, + { + name: "upsert", + description: "Controls whether to insert a new document if no documents match the filter", + type: "boolean", + required: false, + }, + ] + ); + + validateThrowsForInvalidArguments(integration, "update-many", [ + {}, + { database: 123, collection: "bar", update: {} }, + { database: [], collection: "bar", update: {} }, + { database: "test", collection: "bar", update: [] }, + { database: "test", collection: "bar", update: {}, extra: true }, + { database: "test", collection: "bar", update: {}, filter: 123 }, + { database: "test", collection: "bar", update: {}, upsert: "true" }, + { database: "test", collection: "bar", update: {}, filter: {}, upsert: "true" }, + { database: "test", collection: "bar", update: {}, filter: "TRUEPREDICATE", upsert: false }, + ]); + + describe("with non-existent database", () => { + it("doesn't update any documents", async () => { + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: "non-existent-db", + collection: "coll1", + update: { $set: { name: "new-name" } }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual("No documents matched the filter."); + }); + }); + + describe("with non-existent collection", () => { + it("doesn't update any documents", async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .insertOne({ name: "old-name" }); + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: integration.randomDbName(), + collection: "non-existent", + update: { $set: { name: "new-name" } }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual("No documents matched the filter."); + }); + }); + + describe("with existing collection", () => { + beforeEach(async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .insertMany([ + { name: "old-name", value: 1 }, + { name: "old-name", value: 2 }, + { name: "old-name", value: 3 }, + ]); + }); + it("updates all documents without filter", async () => { + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + update: { $set: { name: "new-name" } }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual("Matched 3 document(s). Modified 3 document(s)."); + + const docs = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .find({}) + .toArray(); + + expect(docs).toHaveLength(3); + for (const doc of docs) { + expect(doc.name).toEqual("new-name"); + } + }); + + it("updates all documents that match the filter", async () => { + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + update: { $set: { name: "new-name" } }, + filter: { value: { $gt: 1 } }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toEqual("Matched 2 document(s). Modified 2 document(s)."); + + const docs = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .find({}) + .toArray(); + expect(docs).toHaveLength(3); + for (const doc of docs) { + if (doc.value > 1) { + expect(doc.name).toEqual("new-name"); + } else { + expect(doc.name).toEqual("old-name"); + } + } + }); + + it("upserts a new document if no documents match the filter", async () => { + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + update: { $set: { name: "new-name" } }, + filter: { value: 4 }, + upsert: true, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain("Matched 0 document(s). Upserted 1 document with id:"); + + const docs = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .find({}) + .toArray(); + + expect(docs).toHaveLength(4); + for (const doc of docs) { + if (doc.value === 4) { + expect(doc.name).toEqual("new-name"); + } else { + expect(doc.name).toEqual("old-name"); + } + } + }); + + it("doesn't upsert a new document if no documents match the filter and upsert is false", async () => { + await integration.connectMcpClient(); + + const response = await integration.mcpClient().callTool({ + name: "update-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + update: { $set: { name: "new-name" } }, + filter: { value: 4 }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain("No documents matched the filter."); + + const docs = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .find({}) + .toArray(); + + expect(docs).toHaveLength(3); + for (const doc of docs) { + expect(doc.name).toEqual("old-name"); + } + }); + }); + + validateAutoConnectBehavior(integration, "update-many", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + update: { $set: { name: "new-name" } }, + }, + expectedResponse: "No documents matched the filter.", + }; + }); +});