diff --git a/src/tools/mongodb/delete/deleteMany.ts b/src/tools/mongodb/delete/deleteMany.ts index 834b2aaa..9e6e9fde 100644 --- a/src/tools/mongodb/delete/deleteMany.ts +++ b/src/tools/mongodb/delete/deleteMany.ts @@ -29,7 +29,7 @@ export class DeleteManyTool extends MongoDBToolBase { return { content: [ { - text: `Deleted \`${result.deletedCount}\` documents from collection \`${collection}\``, + text: `Deleted \`${result.deletedCount}\` document(s) from collection "${collection}"`, type: "text", }, ], diff --git a/src/tools/mongodb/delete/deleteOne.ts b/src/tools/mongodb/delete/deleteOne.ts deleted file mode 100644 index 137d5351..00000000 --- a/src/tools/mongodb/delete/deleteOne.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; -import { ToolArgs, OperationType } from "../../tool.js"; - -export class DeleteOneTool extends MongoDBToolBase { - protected name = "delete-one"; - protected description = "Removes a single document that match the filter from a MongoDB collection"; - protected argsShape = { - ...DbOperationArgs, - filter: z - .object({}) - .passthrough() - .optional() - .describe( - "The query filter, specifying the deletion criteria. Matches the syntax of the filter argument of db.collection.deleteMany()" - ), - }; - protected operationType: OperationType = "delete"; - - protected async execute({ - database, - collection, - filter, - }: ToolArgs): Promise { - const provider = await this.ensureConnected(); - const result = await provider.deleteOne(database, collection, filter); - - return { - content: [ - { - text: `Deleted \`${result.deletedCount}\` documents from collection \`${collection}\``, - type: "text", - }, - ], - }; - } -} diff --git a/src/tools/mongodb/delete/dropCollection.ts b/src/tools/mongodb/delete/dropCollection.ts index 9ed1a7c8..ac914f75 100644 --- a/src/tools/mongodb/delete/dropCollection.ts +++ b/src/tools/mongodb/delete/dropCollection.ts @@ -18,7 +18,7 @@ export class DropCollectionTool extends MongoDBToolBase { return { content: [ { - text: `${result ? "Successfully dropped" : "Failed to drop"} collection \`${collection}\` from database \`${database}\``, + text: `${result ? "Successfully dropped" : "Failed to drop"} collection "${collection}" from database "${database}"`, type: "text", }, ], diff --git a/src/tools/mongodb/delete/dropDatabase.ts b/src/tools/mongodb/delete/dropDatabase.ts index 6a58345d..b10862b2 100644 --- a/src/tools/mongodb/delete/dropDatabase.ts +++ b/src/tools/mongodb/delete/dropDatabase.ts @@ -17,7 +17,7 @@ export class DropDatabaseTool extends MongoDBToolBase { return { content: [ { - text: `${result.ok ? "Successfully dropped" : "Failed to drop"} database \`${database}\``, + text: `${result.ok ? "Successfully dropped" : "Failed to drop"} database "${database}"`, type: "text", }, ], diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index 1c59889a..eddbd26b 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -7,12 +7,10 @@ import { CollectionSchemaTool } from "./metadata/collectionSchema.js"; import { FindTool } from "./read/find.js"; import { InsertManyTool } from "./create/insertMany.js"; import { DeleteManyTool } from "./delete/deleteMany.js"; -import { DeleteOneTool } from "./delete/deleteOne.js"; import { CollectionStorageSizeTool } from "./metadata/collectionStorageSize.js"; import { CountTool } from "./read/count.js"; import { DbStatsTool } from "./metadata/dbStats.js"; import { AggregateTool } from "./read/aggregate.js"; -import { UpdateOneTool } from "./update/updateOne.js"; import { UpdateManyTool } from "./update/updateMany.js"; import { RenameCollectionTool } from "./update/renameCollection.js"; import { DropDatabaseTool } from "./delete/dropDatabase.js"; @@ -30,12 +28,10 @@ export const MongoDbTools = [ FindTool, InsertManyTool, DeleteManyTool, - DeleteOneTool, CollectionStorageSizeTool, CountTool, DbStatsTool, AggregateTool, - UpdateOneTool, UpdateManyTool, RenameCollectionTool, DropDatabaseTool, diff --git a/src/tools/mongodb/update/updateOne.ts b/src/tools/mongodb/update/updateOne.ts deleted file mode 100644 index a1dad643..00000000 --- a/src/tools/mongodb/update/updateOne.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from "zod"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { MongoDBToolBase } from "../mongodbTool.js"; -import { ToolArgs, OperationType } from "../../tool.js"; - -export class UpdateOneTool extends MongoDBToolBase { - protected name = "update-one"; - protected description = "Updates a single document within the collection based on the filter"; - protected argsShape = { - collection: z.string().describe("Collection name"), - database: z.string().describe("Database name"), - filter: z - .object({}) - .passthrough() - .optional() - .describe( - "The selection criteria for the update, matching the syntax of the filter argument of db.collection.updateOne()" - ), - update: z - .object({}) - .passthrough() - .optional() - .describe("An update document describing the modifications to apply using update operator expressions"), - upsert: z - .boolean() - .optional() - .describe("Controls whether to insert a new document if no documents match the filter"), - }; - protected operationType: OperationType = "update"; - - protected async execute({ - database, - collection, - filter, - update, - upsert, - }: ToolArgs): Promise { - const provider = await this.ensureConnected(); - const result = await provider.updateOne(database, collection, filter, update, { - upsert, - }); - - let message = ""; - if (result.matchedCount === 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()}).`; - } - } - - return { - content: [ - { - text: message, - type: "text", - }, - ], - }; - } -} diff --git a/tests/integration/tools/mongodb/create/createCollection.test.ts b/tests/integration/tools/mongodb/create/createCollection.test.ts index bc983c2a..042ea7f5 100644 --- a/tests/integration/tools/mongodb/create/createCollection.test.ts +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -126,7 +126,7 @@ describe("createCollection tool", () => { expect(content).toEqual(`Collection "new-collection" created in database "${integration.randomDbName()}".`); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration.mcpClient().callTool({ name: "create-collection", arguments: { database: integration.randomDbName(), collection: "new-collection" }, diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 34c6f37c..c30ee90c 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -233,7 +233,7 @@ describe("createIndex tool", () => { ); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration.mcpClient().callTool({ name: "create-index", arguments: { diff --git a/tests/integration/tools/mongodb/create/insertMany.test.ts b/tests/integration/tools/mongodb/create/insertMany.test.ts index d89e4893..2b413d27 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -125,7 +125,7 @@ describe("insertMany tool", () => { expect(content).toContain('Inserted `1` document(s) into collection "coll1"'); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration.mcpClient().callTool({ name: "insert-many", arguments: { diff --git a/tests/integration/tools/mongodb/delete/deleteMany.test.ts b/tests/integration/tools/mongodb/delete/deleteMany.test.ts new file mode 100644 index 00000000..2ba7d06a --- /dev/null +++ b/tests/integration/tools/mongodb/delete/deleteMany.test.ts @@ -0,0 +1,191 @@ +import { + getResponseContent, + validateParameters, + dbOperationParameters, + setupIntegrationTest, +} from "../../../helpers.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import config from "../../../../../src/config.js"; + +describe("deleteMany tool", () => { + const integration = setupIntegrationTest(); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const deleteMany = tools.find((tool) => tool.name === "delete-many")!; + expect(deleteMany).toBeDefined(); + expect(deleteMany.description).toBe("Removes all documents that match the filter from a MongoDB collection"); + + validateParameters(deleteMany, [ + ...dbOperationParameters, + { + name: "filter", + type: "object", + description: + "The query filter, specifying the deletion criteria. Matches the syntax of the filter argument of db.collection.deleteMany()", + required: false, + }, + ]); + }); + + describe("with invalid arguments", () => { + const args = [ + {}, + { collection: "bar", database: 123, filter: {} }, + { collection: [], database: "test", filter: {} }, + { collection: "bar", database: "test", filter: "my-document" }, + { collection: "bar", database: "test", filter: [{ name: "Peter" }] }, + ]; + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name: "delete-many", arguments: arg }); + expect.fail("Expected an error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const mcpError = error as McpError; + expect(mcpError.code).toEqual(-32602); + expect(mcpError.message).toContain("Invalid arguments for tool delete-many"); + } + }); + } + }); + + it("doesn't create the collection if it doesn't exist", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: {}, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain('Deleted `0` document(s) from collection "coll1"'); + + const collections = await integration.mongoClient().db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(0); + }); + + const insertDocuments = async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .insertMany([ + { age: 10, name: "Peter" }, + { age: 20, name: "John" }, + { age: 30, name: "Mary" }, + { age: 40, name: "Lucy" }, + ]); + }; + + const validateDocuments = async (expected: object[]) => { + const documents = await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("coll1") + .find() + .toArray(); + + expect(documents).toHaveLength(expected.length); + for (const expectedDocument of expected) { + expect(documents).toContainEqual(expect.objectContaining(expectedDocument)); + } + }; + + it("deletes documents matching the filter", async () => { + await insertDocuments(); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: { age: { $gt: 20 } }, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain('Deleted `2` document(s) from collection "coll1"'); + + await validateDocuments([ + { age: 10, name: "Peter" }, + { age: 20, name: "John" }, + ]); + }); + + it("when filter doesn't match, deletes nothing", async () => { + await insertDocuments(); + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: { age: { $gt: 100 } }, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain('Deleted `0` document(s) from collection "coll1"'); + + await validateDocuments([ + { age: 10, name: "Peter" }, + { age: 20, name: "John" }, + { age: 30, name: "Mary" }, + { age: 40, name: "Lucy" }, + ]); + }); + + it("with empty filter, deletes all documents", async () => { + await insertDocuments(); + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: {}, + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain('Deleted `4` document(s) from collection "coll1"'); + + await validateDocuments([]); + }); + + describe("when not connected", () => { + it("connects automatically if connection string is configured", async () => { + config.connectionString = integration.connectionString(); + + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: {}, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain('Deleted `0` document(s) from collection "coll1"'); + }); + + it("throws an error if connection string is not configured", async () => { + const response = await integration.mcpClient().callTool({ + name: "delete-many", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + filter: {}, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/delete/dropCollection.test.ts b/tests/integration/tools/mongodb/delete/dropCollection.test.ts new file mode 100644 index 00000000..a82152ed --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropCollection.test.ts @@ -0,0 +1,118 @@ +import { + getResponseContent, + validateParameters, + dbOperationParameters, + setupIntegrationTest, +} from "../../../helpers.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import config from "../../../../../src/config.js"; + +describe("dropCollection tool", () => { + const integration = setupIntegrationTest(); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const dropCollection = tools.find((tool) => tool.name === "drop-collection")!; + expect(dropCollection).toBeDefined(); + expect(dropCollection.description).toBe( + "Removes a collection or view from the database. The method also removes any indexes associated with the dropped collection." + ); + + validateParameters(dropCollection, [...dbOperationParameters]); + }); + + describe("with invalid arguments", () => { + const args = [ + {}, + { database: 123, collection: "bar" }, + { foo: "bar", database: "test", collection: "bar" }, + { collection: [], database: "test" }, + ]; + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name: "drop-collection", arguments: arg }); + expect.fail("Expected an error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const mcpError = error as McpError; + expect(mcpError.code).toEqual(-32602); + expect(mcpError.message).toContain("Invalid arguments for tool drop-collection"); + } + }); + } + }); + + it("can drop non-existing collection", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-collection", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain( + `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"` + ); + + const collections = await integration.mongoClient().db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(0); + }); + + it("removes the collection if it exists", async () => { + await integration.connectMcpClient(); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll2"); + const response = await integration.mcpClient().callTool({ + name: "drop-collection", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain( + `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"` + ); + const collections = await integration.mongoClient().db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0].name).toBe("coll2"); + }); + + describe("when not connected", () => { + it("connects automatically if connection string is configured", async () => { + await integration.connectMcpClient(); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + + config.connectionString = integration.connectionString(); + + const response = await integration.mcpClient().callTool({ + name: "drop-collection", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain( + `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"` + ); + }); + + it("throws an error if connection string is not configured", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-collection", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts new file mode 100644 index 00000000..80058cf0 --- /dev/null +++ b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts @@ -0,0 +1,114 @@ +import { + getResponseContent, + validateParameters, + dbOperationParameters, + setupIntegrationTest, +} from "../../../helpers.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import config from "../../../../../src/config.js"; + +describe("dropDatabase tool", () => { + const integration = setupIntegrationTest(); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const dropDatabase = tools.find((tool) => tool.name === "drop-database")!; + expect(dropDatabase).toBeDefined(); + expect(dropDatabase.description).toBe("Removes the specified database, deleting the associated data files"); + + validateParameters(dropDatabase, [dbOperationParameters.find((d) => d.name === "database")!]); + }); + + describe("with invalid arguments", () => { + const args = [{}, { database: 123 }, { foo: "bar", database: "test" }]; + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name: "drop-database", arguments: arg }); + expect.fail("Expected an error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const mcpError = error as McpError; + expect(mcpError.code).toEqual(-32602); + expect(mcpError.message).toContain("Invalid arguments for tool drop-database"); + } + }); + } + }); + + it("can drop non-existing database", async () => { + let { databases } = await integration.mongoClient().db("").admin().listDatabases(); + + const preDropLength = databases.length; + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { + database: integration.randomDbName(), + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped database "${integration.randomDbName()}"`); + + ({ databases } = await integration.mongoClient().db("").admin().listDatabases()); + + expect(databases).toHaveLength(preDropLength); + expect(databases.find((db) => db.name === integration.randomDbName())).toBeUndefined(); + }); + + it("removes the database along with its collections", async () => { + await integration.connectMcpClient(); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll2"); + + let { databases } = await integration.mongoClient().db("").admin().listDatabases(); + expect(databases.find((db) => db.name === integration.randomDbName())).toBeDefined(); + + const response = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { + database: integration.randomDbName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped database "${integration.randomDbName()}"`); + + ({ databases } = await integration.mongoClient().db("").admin().listDatabases()); + expect(databases.find((db) => db.name === integration.randomDbName())).toBeUndefined(); + + const collections = await integration.mongoClient().db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(0); + }); + + describe("when not connected", () => { + it("connects automatically if connection string is configured", async () => { + await integration.connectMcpClient(); + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + + config.connectionString = integration.connectionString(); + + const response = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { + database: integration.randomDbName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(`Successfully dropped database "${integration.randomDbName()}"`); + }); + + it("throws an error if connection string is not configured", async () => { + const response = await integration.mcpClient().callTool({ + name: "drop-database", + arguments: { + database: integration.randomDbName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/listCollections.test.ts b/tests/integration/tools/mongodb/metadata/listCollections.test.ts index 47f1e3de..a88599f5 100644 --- a/tests/integration/tools/mongodb/metadata/listCollections.test.ts +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -93,7 +93,7 @@ describe("listCollections tool", () => { ); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration .mcpClient() .callTool({ name: "list-collections", arguments: { database: integration.randomDbName() } }); diff --git a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts index 33ca83d7..fd196541 100644 --- a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts +++ b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts @@ -25,7 +25,7 @@ describe("listDatabases tool", () => { expect(dbNames).toIncludeSameMembers(["admin", "config", "local"]); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration.mcpClient().callTool({ name: "list-databases", arguments: {} }); const content = getResponseContent(response.content); expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); diff --git a/tests/integration/tools/mongodb/read/count.test.ts b/tests/integration/tools/mongodb/read/count.test.ts index b775dfdd..4fbadf93 100644 --- a/tests/integration/tools/mongodb/read/count.test.ts +++ b/tests/integration/tools/mongodb/read/count.test.ts @@ -126,7 +126,7 @@ describe("count tool", () => { expect(content).toEqual('Found 0 documents in the collection "coll1"'); }); - it("throw an error if connection string is not configured", async () => { + it("throws an error if connection string is not configured", async () => { const response = await integration.mcpClient().callTool({ name: "count", arguments: { database: randomDbName, collection: "coll1" },