From ba5495242da0fa66a39ebcde9537a232262ac575 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Tue, 22 Apr 2025 22:41:45 +0200 Subject: [PATCH 1/4] chore: add tests for collection-schema --- .../mongodb/metadata/collectionSchema.ts | 30 +-- tests/integration/helpers.ts | 57 +++++- .../mongodb/create/createCollection.test.ts | 40 ++-- .../tools/mongodb/create/createIndex.test.ts | 39 +--- .../tools/mongodb/create/insertMany.test.ts | 60 +++--- .../tools/mongodb/delete/deleteMany.test.ts | 60 +++--- .../mongodb/delete/dropCollection.test.ts | 46 ++--- .../tools/mongodb/delete/dropDatabase.test.ts | 43 ++--- .../mongodb/metadata/collectionSchema.test.ts | 174 ++++++++++++++++++ .../tools/mongodb/metadata/connect.test.ts | 9 +- .../mongodb/metadata/listCollections.test.ts | 53 +++--- .../mongodb/metadata/listDatabases.test.ts | 53 +++--- .../tools/mongodb/read/count.test.ts | 56 +++--- 13 files changed, 414 insertions(+), 306 deletions(-) create mode 100644 tests/integration/tools/mongodb/metadata/collectionSchema.test.ts diff --git a/src/tools/mongodb/metadata/collectionSchema.ts b/src/tools/mongodb/metadata/collectionSchema.ts index b018c843..c106371b 100644 --- a/src/tools/mongodb/metadata/collectionSchema.ts +++ b/src/tools/mongodb/metadata/collectionSchema.ts @@ -1,7 +1,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; -import { parseSchema, SchemaField } from "mongodb-schema"; +import { getSimplifiedSchema, parseSchema, SchemaField, SchemaType } from "mongodb-schema"; export class CollectionSchemaTool extends MongoDBToolBase { protected name = "collection-schema"; @@ -13,29 +13,31 @@ export class CollectionSchemaTool extends MongoDBToolBase { protected async execute({ database, collection }: ToolArgs): Promise { const provider = await this.ensureConnected(); const documents = await provider.find(database, collection, {}, { limit: 5 }).toArray(); - const schema = await parseSchema(documents); + const schema = await getSimplifiedSchema(documents); + + const fieldsCount = Object.entries(schema).length; + if (fieldsCount === 0) { + return { + content: [ + { + text: `Could not deduce the schema for "${database}.${collection}". This may be because it doesn't exist or is empty.`, + type: "text", + }, + ], + }; + } return { content: [ { - text: `Found ${schema.fields.length} fields in the schema for \`${database}.${collection}\``, + text: `Found ${fieldsCount} fields in the schema for "${database}.${collection}"`, type: "text", }, { - text: this.formatFieldOutput(schema.fields), + text: JSON.stringify(schema), type: "text", }, ], }; } - - private formatFieldOutput(fields: SchemaField[]): string { - let result = "| Field | Type | Confidence |\n"; - result += "|-------|------|-------------|\n"; - for (const field of fields) { - const fieldType = Array.isArray(field.type) ? field.type.join(", ") : field.type; - result += `| ${field.name} | \`${fieldType}\` | ${(field.probability * 100).toFixed(0)}% |\n`; - } - return result; - } } diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index bd951979..4fa1d321 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -19,13 +19,15 @@ interface ParameterInfo { type ToolInfo = Awaited>["tools"][number]; -export function setupIntegrationTest(): { +interface IntegrationTestSetup { mcpClient: () => Client; mongoClient: () => MongoClient; connectionString: () => string; connectMcpClient: () => Promise; randomDbName: () => string; -} { +} + +export function setupIntegrationTest(): IntegrationTestSetup { let mongoCluster: runner.MongoCluster | undefined; let mongoClient: MongoClient | undefined; @@ -208,8 +210,57 @@ export const dbOperationParameters: ParameterInfo[] = [ { name: "collection", type: "string", description: "Collection name", required: true }, ]; -export function validateParameters(tool: ToolInfo, parameters: ParameterInfo[]): void { +export async function validateToolMetadata( + mcpClient: Client, + name: string, + description: string, + parameters: ParameterInfo[] +): Promise { + const { tools } = await mcpClient.listTools(); + const tool = tools.find((tool) => tool.name === name)!; + expect(tool).toBeDefined(); + expect(tool.description).toBe(description); + const toolParameters = getParameters(tool); expect(toolParameters).toHaveLength(parameters.length); expect(toolParameters).toIncludeAllMembers(parameters); } + +export function validateAutoConnectBehavior( + integration: IntegrationTestSetup, + name: string, + validation: () => { + args: { [x: string]: unknown }; + expectedResponse?: string; + validate?: (content: unknown) => void; + } +): void { + it("connects automatically if connection string is configured", async () => { + config.connectionString = integration.connectionString(); + + const validationInfo = validation(); + + const response = await integration.mcpClient().callTool({ + name, + arguments: validationInfo.args, + }); + + if (validationInfo.expectedResponse) { + const content = getResponseContent(response.content); + expect(content).toContain(validationInfo.expectedResponse); + } + + if (validationInfo.validate) { + validationInfo.validate(response.content); + } + }); + + it("throws an error if connection string is not configured", async () => { + const response = await integration.mcpClient().callTool({ + name, + arguments: validation().args, + }); + 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/create/createCollection.test.ts b/tests/integration/tools/mongodb/create/createCollection.test.ts index 042ea7f5..525654f1 100644 --- a/tests/integration/tools/mongodb/create/createCollection.test.ts +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -1,26 +1,23 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { ObjectId } from "bson"; -import config from "../../../../../src/config.js"; describe("createCollection tool", () => { const integration = setupIntegrationTest(); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const listCollections = tools.find((tool) => tool.name === "create-collection")!; - expect(listCollections).toBeDefined(); - expect(listCollections.description).toBe( - "Creates a new collection in a database. If the database doesn't exist, it will be created automatically." + await validateToolMetadata( + integration.mcpClient(), + "create-collection", + "Creates a new collection in a database. If the database doesn't exist, it will be created automatically.", + dbOperationParameters ); - - validateParameters(listCollections, dbOperationParameters); }); describe("with invalid arguments", () => { @@ -115,24 +112,11 @@ describe("createCollection tool", () => { }); describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "create-collection", - arguments: { database: integration.randomDbName(), collection: "new-collection" }, - }); - const content = getResponseContent(response.content); - expect(content).toEqual(`Collection "new-collection" created in database "${integration.randomDbName()}".`); - }); - - 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" }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + validateAutoConnectBehavior(integration, "create-collection", () => { + return { + args: { database: integration.randomDbName(), collection: "new-collection" }, + expectedResponse: `Collection "new-collection" created in database "${integration.randomDbName()}".`, + }; }); }); }); diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index c30ee90c..5f63537a 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { IndexDirection } from "mongodb"; @@ -12,12 +13,7 @@ describe("createIndex tool", () => { const integration = setupIntegrationTest(); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const createIndex = tools.find((tool) => tool.name === "create-index")!; - expect(createIndex).toBeDefined(); - expect(createIndex.description).toBe("Create an index for a collection"); - - validateParameters(createIndex, [ + await validateToolMetadata(integration.mcpClient(), "create-index", "Create an index for a collection", [ ...dbOperationParameters, { name: "keys", @@ -216,34 +212,15 @@ describe("createIndex tool", () => { } describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { + validateAutoConnectBehavior(integration, "create-index", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 }, }, - }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); - }); - - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - keys: { prop1: 1 }, - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, + }; }); }); }); diff --git a/tests/integration/tools/mongodb/create/insertMany.test.ts b/tests/integration/tools/mongodb/create/insertMany.test.ts index 2b413d27..a8afb2ba 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; @@ -11,21 +12,21 @@ describe("insertMany tool", () => { const integration = setupIntegrationTest(); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const insertMany = tools.find((tool) => tool.name === "insert-many")!; - expect(insertMany).toBeDefined(); - expect(insertMany.description).toBe("Insert an array of documents into a MongoDB collection"); - - validateParameters(insertMany, [ - ...dbOperationParameters, - { - name: "documents", - type: "array", - description: - "The array of documents to insert, matching the syntax of the document argument of db.collection.insertMany()", - required: true, - }, - ]); + validateToolMetadata( + integration.mcpClient(), + "insert-many", + "Insert an array of documents into a MongoDB collection", + [ + ...dbOperationParameters, + { + name: "documents", + type: "array", + description: + "The array of documents to insert, matching the syntax of the document argument of db.collection.insertMany()", + required: true, + }, + ] + ); }); describe("with invalid arguments", () => { @@ -110,32 +111,15 @@ describe("insertMany tool", () => { }); describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "insert-many", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - documents: [{ prop1: "value1" }], - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain('Inserted `1` document(s) into collection "coll1"'); - }); - - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name: "insert-many", - arguments: { + validateAutoConnectBehavior(integration, "insert-many", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1", documents: [{ prop1: "value1" }], }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); + expectedResponse: 'Inserted `1` document(s) into collection "coll1"', + }; }); }); }); diff --git a/tests/integration/tools/mongodb/delete/deleteMany.test.ts b/tests/integration/tools/mongodb/delete/deleteMany.test.ts index 2ba7d06a..67cf9540 100644 --- a/tests/integration/tools/mongodb/delete/deleteMany.test.ts +++ b/tests/integration/tools/mongodb/delete/deleteMany.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; @@ -11,21 +12,21 @@ 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, - }, - ]); + await validateToolMetadata( + integration.mcpClient(), + "delete-many", + "Removes all documents that match the filter from a MongoDB collection", + [ + ...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", () => { @@ -160,32 +161,15 @@ describe("deleteMany tool", () => { }); 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: { + validateAutoConnectBehavior(integration, "delete-many", () => { + return { + args: { 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."); + expectedResponse: 'Deleted `0` document(s) from collection "coll1"', + }; }); }); }); diff --git a/tests/integration/tools/mongodb/delete/dropCollection.test.ts b/tests/integration/tools/mongodb/delete/dropCollection.test.ts index a82152ed..5d1e61c9 100644 --- a/tests/integration/tools/mongodb/delete/dropCollection.test.ts +++ b/tests/integration/tools/mongodb/delete/dropCollection.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; @@ -11,14 +12,12 @@ 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." + await validateToolMetadata( + integration.mcpClient(), + "drop-collection", + "Removes a collection or view from the database. The method also removes any indexes associated with the dropped collection.", + dbOperationParameters ); - - validateParameters(dropCollection, [...dbOperationParameters]); }); describe("with invalid arguments", () => { @@ -84,35 +83,14 @@ describe("dropCollection tool", () => { }); 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: { + validateAutoConnectBehavior(integration, "drop-collection", () => { + return { + args: { 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."); + expectedResponse: `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"`, + }; }); }); }); diff --git a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts index 80058cf0..8c97d366 100644 --- a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts +++ b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; @@ -11,12 +12,12 @@ 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")!]); + await validateToolMetadata( + integration.mcpClient(), + "drop-database", + "Removes the specified database, deleting the associated data files", + [dbOperationParameters.find((d) => d.name === "database")!] + ); }); describe("with invalid arguments", () => { @@ -84,31 +85,15 @@ describe("dropDatabase tool", () => { }); describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - await integration.connectMcpClient(); + beforeEach(async () => { 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."); + validateAutoConnectBehavior(integration, "drop-database", () => { + return { + args: { database: integration.randomDbName() }, + expectedResponse: `Successfully dropped database "${integration.randomDbName()}"`, + }; }); }); }); diff --git a/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts new file mode 100644 index 00000000..5e3f60d2 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts @@ -0,0 +1,174 @@ +import { + getResponseElements, + getResponseContent, + setupIntegrationTest, + dbOperationParameters, + validateToolMetadata, + validateAutoConnectBehavior, +} from "../../../helpers.js"; +import { toIncludeSameMembers } from "jest-extended"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import config from "../../../../../src/config.js"; +import { Document } from "bson"; +import { OptionalId } from "mongodb"; +import { SimplifiedSchema } from "mongodb-schema"; + +describe("collectionSchema tool", () => { + const integration = setupIntegrationTest(); + + it("should have correct metadata", async () => { + await validateToolMetadata( + integration.mcpClient(), + "collection-schema", + "Describe the schema for a collection", + dbOperationParameters + ); + }); + + describe("with invalid arguments", () => { + const args = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name: "collection-schema", 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 collection-schema"); + } + }); + } + }); + + describe("with non-existent database", () => { + it("returns empty schema", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-schema", + arguments: { database: "non-existent", collection: "foo" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Could not deduce the schema for "non-existent.foo". This may be because it doesn't exist or is empty.` + ); + }); + }); + + describe("with existing database", () => { + const testCases: Array<{ + insertionData: OptionalId[]; + name: string; + expectedSchema: SimplifiedSchema; + }> = [ + { + name: "homogenous schema", + insertionData: [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ], + expectedSchema: { + _id: { + types: [{ bsonType: "ObjectId" }], + }, + name: { + types: [{ bsonType: "String" }], + }, + age: { + types: [{ bsonType: "Number" as any }], + }, + }, + }, + { + name: "heterogenous schema", + insertionData: [ + { name: "Alice", age: 30 }, + { name: "Bob", age: "25", country: "UK" }, + { name: "Charlie", country: "USA" }, + { name: "Mims", age: 25, country: false }, + ], + expectedSchema: { + _id: { + types: [{ bsonType: "ObjectId" }], + }, + name: { + types: [{ bsonType: "String" }], + }, + age: { + types: [{ bsonType: "Number" as any }, { bsonType: "String" }], + }, + country: { + types: [{ bsonType: "String" }, { bsonType: "Boolean" }], + }, + }, + }, + { + name: "schema with nested documents", + insertionData: [ + { name: "Alice", address: { city: "New York", zip: "10001" }, ageRange: [18, 30] }, + { name: "Bob", address: { city: "Los Angeles" }, ageRange: "25-30" }, + { name: "Charlie", address: { city: "Chicago", zip: "60601" }, ageRange: [20, 35] }, + ], + expectedSchema: { + _id: { + types: [{ bsonType: "ObjectId" }], + }, + name: { + types: [{ bsonType: "String" }], + }, + address: { + types: [ + { + bsonType: "Document", + fields: { + city: { types: [{ bsonType: "String" }] }, + zip: { types: [{ bsonType: "String" }] }, + }, + }, + ], + }, + ageRange: { + types: [{ bsonType: "Array", types: [{ bsonType: "Number" as any }] }, { bsonType: "String" }], + }, + }, + }, + ]; + + for (const testCase of testCases) { + it(`returns ${testCase.name}`, async () => { + const mongoClient = integration.mongoClient(); + await mongoClient.db(integration.randomDbName()).collection("foo").insertMany(testCase.insertionData); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-schema", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + const items = getResponseElements(response.content); + expect(items).toHaveLength(2); + + // Expect to find _id, name, age + expect(items[0].text).toEqual( + `Found ${Object.entries(testCase.expectedSchema).length} fields in the schema for "${integration.randomDbName()}.foo"` + ); + + const schema = JSON.parse(items[1].text) as SimplifiedSchema; + expect(schema).toEqual(testCase.expectedSchema); + }); + } + }); + + describe("when not connected", () => { + validateAutoConnectBehavior(integration, "collection-schema", () => { + return { + args: { + database: integration.randomDbName(), + collection: "new-collection", + }, + expectedResponse: `Could not deduce the schema for "${integration.randomDbName()}.new-collection". This may be because it doesn't exist or is empty.`, + }; + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/connect.test.ts b/tests/integration/tools/mongodb/metadata/connect.test.ts index a62f5e8d..0d063b42 100644 --- a/tests/integration/tools/mongodb/metadata/connect.test.ts +++ b/tests/integration/tools/mongodb/metadata/connect.test.ts @@ -1,4 +1,4 @@ -import { getResponseContent, validateParameters, setupIntegrationTest } from "../../../helpers.js"; +import { getResponseContent, setupIntegrationTest, validateToolMetadata } from "../../../helpers.js"; import config from "../../../../../src/config.js"; @@ -6,12 +6,7 @@ describe("Connect tool", () => { const integration = setupIntegrationTest(); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const connectTool = tools.find((tool) => tool.name === "connect")!; - expect(connectTool).toBeDefined(); - expect(connectTool.description).toBe("Connect to a MongoDB instance"); - - validateParameters(connectTool, [ + validateToolMetadata(integration.mcpClient(), "connect", "Connect to a MongoDB instance", [ { name: "options", description: diff --git a/tests/integration/tools/mongodb/metadata/listCollections.test.ts b/tests/integration/tools/mongodb/metadata/listCollections.test.ts index a88599f5..de3ee19b 100644 --- a/tests/integration/tools/mongodb/metadata/listCollections.test.ts +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -1,21 +1,24 @@ -import { getResponseElements, getResponseContent, validateParameters, setupIntegrationTest } from "../../../helpers.js"; +import { + getResponseElements, + getResponseContent, + setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, +} from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; -import { ObjectId } from "bson"; describe("listCollections tool", () => { const integration = setupIntegrationTest(); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const listCollections = tools.find((tool) => tool.name === "list-collections")!; - expect(listCollections).toBeDefined(); - expect(listCollections.description).toBe("List all collections for a given database"); - - validateParameters(listCollections, [ - { name: "database", description: "Database name", type: "string", required: true }, - ]); + await validateToolMetadata( + integration.mcpClient(), + "list-collections", + "List all collections for a given database", + [{ name: "database", description: "Database name", type: "string", required: true }] + ); }); describe("with invalid arguments", () => { @@ -46,7 +49,7 @@ describe("listCollections tool", () => { }); const content = getResponseContent(response.content); expect(content).toEqual( - `No collections found for database "non-existent". To create a collection, use the "create-collection" tool.` + 'No collections found for database "non-existent". To create a collection, use the "create-collection" tool.' ); }); }); @@ -81,24 +84,16 @@ describe("listCollections tool", () => { }); describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration - .mcpClient() - .callTool({ name: "list-collections", arguments: { database: integration.randomDbName() } }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `No collections found for database "${integration.randomDbName()}". To create a collection, use the "create-collection" tool.` - ); - }); + validateAutoConnectBehavior( + integration, + "list-collections", - it("throws an error if connection string is not configured", async () => { - const response = await integration - .mcpClient() - .callTool({ name: "list-collections", 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."); - }); + () => { + return { + args: { database: integration.randomDbName() }, + expectedResponse: `No collections found for database "${integration.randomDbName()}". To create a collection, use the "create-collection" tool.`, + }; + } + ); }); }); diff --git a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts index fd196541..7785b067 100644 --- a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts +++ b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts @@ -1,9 +1,14 @@ -import config from "../../../../../src/config.js"; -import { getResponseElements, getParameters, setupIntegrationTest, getResponseContent } from "../../../helpers.js"; +import { + getResponseElements, + getParameters, + setupIntegrationTest, + validateAutoConnectBehavior, +} from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; describe("listDatabases tool", () => { const integration = setupIntegrationTest(); + const defaultDatabases = ["admin", "config", "local"]; it("should have correct metadata", async () => { const { tools } = await integration.mcpClient().listTools(); @@ -15,30 +20,13 @@ describe("listDatabases tool", () => { expect(parameters).toHaveLength(0); }); - describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ name: "list-databases", arguments: {} }); - const dbNames = getDbNames(response.content); - - expect(dbNames).toIncludeSameMembers(["admin", "config", "local"]); - }); - - 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."); - }); - }); - describe("with no preexisting databases", () => { it("returns only the system databases", async () => { await integration.connectMcpClient(); const response = await integration.mcpClient().callTool({ name: "list-databases", arguments: {} }); const dbNames = getDbNames(response.content); - expect(dbNames).toIncludeSameMembers(["admin", "config", "local"]); + expect(dbNames).toIncludeSameMembers(defaultDatabases); }); }); @@ -52,7 +40,30 @@ describe("listDatabases tool", () => { const response = await integration.mcpClient().callTool({ name: "list-databases", arguments: {} }); const dbNames = getDbNames(response.content); - expect(dbNames).toIncludeSameMembers(["admin", "config", "local", "foo", "baz"]); + expect(dbNames).toIncludeSameMembers([...defaultDatabases, "foo", "baz"]); + }); + }); + + describe("when not connected", () => { + beforeEach(async () => { + const mongoClient = integration.mongoClient(); + const { databases } = await mongoClient.db("admin").command({ listDatabases: 1, nameOnly: true }); + for (const db of databases) { + if (!defaultDatabases.includes(db.name)) { + await mongoClient.db(db.name).dropDatabase(); + } + } + }); + + validateAutoConnectBehavior(integration, "list-databases", () => { + return { + args: {}, + validate: (content) => { + const dbNames = getDbNames(content); + + expect(dbNames).toIncludeSameMembers(defaultDatabases); + }, + }; }); }); }); diff --git a/tests/integration/tools/mongodb/read/count.test.ts b/tests/integration/tools/mongodb/read/count.test.ts index 4fbadf93..f15b7e7c 100644 --- a/tests/integration/tools/mongodb/read/count.test.ts +++ b/tests/integration/tools/mongodb/read/count.test.ts @@ -1,8 +1,9 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; @@ -18,21 +19,21 @@ describe("count tool", () => { }); it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const listCollections = tools.find((tool) => tool.name === "count")!; - expect(listCollections).toBeDefined(); - expect(listCollections.description).toBe("Gets the number of documents in a MongoDB collection"); - - validateParameters(listCollections, [ - { - name: "query", - description: - "The query filter to count documents. Matches the syntax of the filter argument of db.collection.count()", - type: "object", - required: false, - }, - ...dbOperationParameters, - ]); + await validateToolMetadata( + integration.mcpClient(), + "count", + "Gets the number of documents in a MongoDB collection", + [ + { + name: "query", + description: + "The query filter to count documents. Matches the syntax of the filter argument of db.collection.count()", + type: "object", + required: false, + }, + ...dbOperationParameters, + ] + ); }); describe("with invalid arguments", () => { @@ -115,24 +116,11 @@ describe("count tool", () => { }); describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "count", - arguments: { database: randomDbName, collection: "coll1" }, - }); - const content = getResponseContent(response.content); - expect(content).toEqual('Found 0 documents in the collection "coll1"'); - }); - - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name: "count", - arguments: { database: 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."); + validateAutoConnectBehavior(integration, "count", () => { + return { + args: { database: randomDbName, collection: "coll1" }, + expectedResponse: 'Found 0 documents in the collection "coll1"', + }; }); }); }); From 0636cc7f2f1f29ff5354131e78da94dd5c0513cf Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Tue, 22 Apr 2025 22:46:34 +0200 Subject: [PATCH 2/4] lint fix --- src/tools/mongodb/metadata/collectionSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/mongodb/metadata/collectionSchema.ts b/src/tools/mongodb/metadata/collectionSchema.ts index c106371b..f0145323 100644 --- a/src/tools/mongodb/metadata/collectionSchema.ts +++ b/src/tools/mongodb/metadata/collectionSchema.ts @@ -1,7 +1,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; -import { getSimplifiedSchema, parseSchema, SchemaField, SchemaType } from "mongodb-schema"; +import { getSimplifiedSchema } from "mongodb-schema"; export class CollectionSchemaTool extends MongoDBToolBase { protected name = "collection-schema"; From 2c736b9629ce95199dbc5dce1a480c5f8208135d Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 23 Apr 2025 01:13:50 +0200 Subject: [PATCH 3/4] chore: add tests for collection-storage-size --- .../mongodb/metadata/collectionStorageSize.ts | 44 ++++++++- src/tools/mongodb/mongodbTool.ts | 9 +- src/tools/tool.ts | 8 +- tests/integration/helpers.ts | 24 +++++ .../mongodb/create/createCollection.test.ts | 23 +---- .../tools/mongodb/create/createIndex.test.ts | 19 +--- .../tools/mongodb/create/insertMany.test.ts | 19 +--- .../tools/mongodb/delete/deleteMany.test.ts | 21 +---- .../mongodb/delete/dropCollection.test.ts | 25 +---- .../tools/mongodb/delete/dropDatabase.test.ts | 20 +--- .../mongodb/metadata/collectionSchema.test.ts | 19 +--- .../metadata/collectionStorageSize.test.ts | 93 +++++++++++++++++++ .../mongodb/metadata/listCollections.test.ts | 19 +--- .../tools/mongodb/read/count.test.ts | 19 +--- 14 files changed, 197 insertions(+), 165 deletions(-) create mode 100644 tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts diff --git a/src/tools/mongodb/metadata/collectionStorageSize.ts b/src/tools/mongodb/metadata/collectionStorageSize.ts index 7c58d66b..127e7172 100644 --- a/src/tools/mongodb/metadata/collectionStorageSize.ts +++ b/src/tools/mongodb/metadata/collectionStorageSize.ts @@ -4,7 +4,7 @@ import { ToolArgs, OperationType } from "../../tool.js"; export class CollectionStorageSizeTool extends MongoDBToolBase { protected name = "collection-storage-size"; - protected description = "Gets the size of the collection in MB"; + protected description = "Gets the size of the collection"; protected argsShape = DbOperationArgs; protected operationType: OperationType = "metadata"; @@ -14,17 +14,55 @@ export class CollectionStorageSizeTool extends MongoDBToolBase { const [{ value }] = (await provider .aggregate(database, collection, [ { $collStats: { storageStats: {} } }, - { $group: { _id: null, value: { $sum: "$storageStats.storageSize" } } }, + { $group: { _id: null, value: { $sum: "$storageStats.size" } } }, ]) .toArray()) as [{ value: number }]; + const { units, value: scaledValue } = CollectionStorageSizeTool.getStats(value); + return { content: [ { - text: `The size of \`${database}.${collection}\` is \`${(value / 1024 / 1024).toFixed(2)} MB\``, + text: `The size of "${database}.${collection}" is \`${scaledValue.toFixed(2)} ${units}\``, type: "text", }, ], }; } + + protected handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + if (error instanceof Error && "codeName" in error && error.codeName === "NamespaceNotFound") { + return { + content: [ + { + text: `The size of "${args.database}.${args.collection}" cannot be determined because the collection does not exist.`, + type: "text", + }, + ], + }; + } + + return super.handleError(error, args); + } + + private static getStats(value: number): { value: number; units: string } { + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + + if (value > gb) { + return { value: value / gb, units: "GB" }; + } + + if (value > mb) { + return { value: value / mb, units: "MB" }; + } + if (value > kb) { + return { value: value / kb, units: "KB" }; + } + return { value, units: "bytes" }; + } } diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index 520d10d5..b79c6b9f 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ToolBase, ToolCategory } from "../tool.js"; +import { ToolArgs, ToolBase, ToolCategory } from "../tool.js"; import { Session } from "../../session.js"; import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; @@ -30,7 +30,10 @@ export abstract class MongoDBToolBase extends ToolBase { return this.session.serviceProvider; } - protected handleError(error: unknown): Promise | CallToolResult { + protected handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { if (error instanceof MongoDBError && error.code === ErrorCodes.NotConnectedToMongoDB) { return { content: [ @@ -47,7 +50,7 @@ export abstract class MongoDBToolBase extends ToolBase { }; } - return super.handleError(error); + return super.handleError(error, args); } protected async connectToMongoDB(connectionString: string): Promise { diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 0fe6e80f..1e8ef234 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -44,7 +44,7 @@ export abstract class ToolBase { } catch (error: unknown) { logger.error(mongoLogId(1_000_000), "tool", `Error executing ${this.name}: ${error as string}`); - return await this.handleError(error); + return await this.handleError(error, args[0] as ToolArgs); } }; @@ -76,7 +76,11 @@ export abstract class ToolBase { } // This method is intended to be overridden by subclasses to handle errors - protected handleError(error: unknown): Promise | CallToolResult { + protected handleError( + error: unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + args: ToolArgs + ): Promise | CallToolResult { return { content: [ { diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 4fa1d321..97a471bd 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { MongoClient, ObjectId } from "mongodb"; import { toIncludeAllMembers } from "jest-extended"; import config from "../../src/config.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; interface ParameterInfo { name: string; @@ -210,6 +211,8 @@ export const dbOperationParameters: ParameterInfo[] = [ { name: "collection", type: "string", description: "Collection name", required: true }, ]; +export const dbOperationInvalidArgTests = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; + export async function validateToolMetadata( mcpClient: Client, name: string, @@ -264,3 +267,24 @@ export function validateAutoConnectBehavior( expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); }); } + +export function validateThrowsForInvalidArguments( + integration: IntegrationTestSetup, + name: string, + args: { [x: string]: unknown }[] +): void { + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name, 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 ${name}`); + } + }); + } +} diff --git a/tests/integration/tools/mongodb/create/createCollection.test.ts b/tests/integration/tools/mongodb/create/createCollection.test.ts index 525654f1..a831e3e9 100644 --- a/tests/integration/tools/mongodb/create/createCollection.test.ts +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -4,6 +4,8 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; @@ -21,26 +23,7 @@ describe("createCollection tool", () => { }); 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: "create-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 create-collection"); - } - }); - } + validateThrowsForInvalidArguments(integration, "create-collection", dbOperationInvalidArgTests); }); describe("with non-existent database", () => { diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 5f63537a..09e44004 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -4,6 +4,7 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { IndexDirection } from "mongodb"; @@ -31,28 +32,14 @@ describe("createIndex tool", () => { }); describe("with invalid arguments", () => { - const args = [ + validateThrowsForInvalidArguments(integration, "create-index", [ {}, { collection: "bar", database: 123, keys: { foo: 1 } }, { collection: "bar", database: "test", keys: { foo: 5 } }, { collection: [], database: "test", keys: { foo: 1 } }, { collection: "bar", database: "test", keys: { foo: 1 }, name: 123 }, { collection: "bar", database: "test", keys: "foo", name: "my-index" }, - ]; - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name: "create-index", 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 create-index"); - } - }); - } + ]); }); const validateIndex = async (collection: string, expected: { name: string; key: object }[]) => { diff --git a/tests/integration/tools/mongodb/create/insertMany.test.ts b/tests/integration/tools/mongodb/create/insertMany.test.ts index a8afb2ba..4ddb94e5 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -4,6 +4,7 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, } from "../../../helpers.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import config from "../../../../../src/config.js"; @@ -30,27 +31,13 @@ describe("insertMany tool", () => { }); describe("with invalid arguments", () => { - const args = [ + validateThrowsForInvalidArguments(integration, "insert-many", [ {}, { collection: "bar", database: 123, documents: [] }, { collection: [], database: "test", documents: [] }, { collection: "bar", database: "test", documents: "my-document" }, { collection: "bar", database: "test", documents: { 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: "insert-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 insert-many"); - } - }); - } + ]); }); const validateDocuments = async (collection: string, expectedDocuments: object[]) => { diff --git a/tests/integration/tools/mongodb/delete/deleteMany.test.ts b/tests/integration/tools/mongodb/delete/deleteMany.test.ts index 67cf9540..0ab5756d 100644 --- a/tests/integration/tools/mongodb/delete/deleteMany.test.ts +++ b/tests/integration/tools/mongodb/delete/deleteMany.test.ts @@ -4,9 +4,8 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, } from "../../../helpers.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; describe("deleteMany tool", () => { const integration = setupIntegrationTest(); @@ -30,27 +29,13 @@ describe("deleteMany tool", () => { }); describe("with invalid arguments", () => { - const args = [ + validateThrowsForInvalidArguments(integration, "delete-many", [ {}, { 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 () => { diff --git a/tests/integration/tools/mongodb/delete/dropCollection.test.ts b/tests/integration/tools/mongodb/delete/dropCollection.test.ts index 5d1e61c9..06a50a84 100644 --- a/tests/integration/tools/mongodb/delete/dropCollection.test.ts +++ b/tests/integration/tools/mongodb/delete/dropCollection.test.ts @@ -4,9 +4,9 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } from "../../../helpers.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; describe("dropCollection tool", () => { const integration = setupIntegrationTest(); @@ -21,26 +21,7 @@ describe("dropCollection tool", () => { }); 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"); - } - }); - } + validateThrowsForInvalidArguments(integration, "drop-collection", dbOperationInvalidArgTests); }); it("can drop non-existing collection", async () => { diff --git a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts index 8c97d366..ceecd5ab 100644 --- a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts +++ b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts @@ -4,9 +4,9 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } from "../../../helpers.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; describe("dropDatabase tool", () => { const integration = setupIntegrationTest(); @@ -21,21 +21,7 @@ describe("dropDatabase tool", () => { }); 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"); - } - }); - } + validateThrowsForInvalidArguments(integration, "drop-database", dbOperationInvalidArgTests); }); it("can drop non-existing database", async () => { diff --git a/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts index 5e3f60d2..9eda6f90 100644 --- a/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts +++ b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts @@ -5,10 +5,11 @@ import { dbOperationParameters, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; import { Document } from "bson"; import { OptionalId } from "mongodb"; import { SimplifiedSchema } from "mongodb-schema"; @@ -26,21 +27,7 @@ describe("collectionSchema tool", () => { }); describe("with invalid arguments", () => { - const args = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name: "collection-schema", 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 collection-schema"); - } - }); - } + validateThrowsForInvalidArguments(integration, "collection-schema", dbOperationInvalidArgTests); }); describe("with non-existent database", () => { diff --git a/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts b/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts new file mode 100644 index 00000000..f91fff86 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts @@ -0,0 +1,93 @@ +import { + getResponseContent, + setupIntegrationTest, + dbOperationParameters, + validateToolMetadata, + validateAutoConnectBehavior, + dbOperationInvalidArgTests, + validateThrowsForInvalidArguments, +} from "../../../helpers.js"; +import * as crypto from "crypto"; + +describe("collectionStorageSize tool", () => { + const integration = setupIntegrationTest(); + + it("should have correct metadata", async () => { + await validateToolMetadata( + integration.mcpClient(), + "collection-storage-size", + "Gets the size of the collection", + dbOperationParameters + ); + }); + + describe("with invalid arguments", () => { + validateThrowsForInvalidArguments(integration, "collection-storage-size", dbOperationInvalidArgTests); + }); + + describe("with non-existent database", () => { + it("returns 0 MB", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-storage-size", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `The size of "${integration.randomDbName()}.foo" cannot be determined because the collection does not exist.` + ); + }); + }); + + describe("with existing database", () => { + const testCases = [ + { + expectedScale: "bytes", + bytesToInsert: 1, + }, + { + expectedScale: "KB", + bytesToInsert: 1024, + }, + { + expectedScale: "MB", + bytesToInsert: 1024 * 1024, + }, + ]; + for (const test of testCases) { + it(`returns the size of the collection in ${test.expectedScale}`, async () => { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("foo") + .insertOne({ data: crypto.randomBytes(test.bytesToInsert) }); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-storage-size", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(`The size of "${integration.randomDbName()}.foo" is`); + const size = /is `(\d+\.\d+) ([a-zA-Z]*)`/.exec(content); + + expect(size?.[1]).toBeDefined(); + expect(size?.[2]).toBeDefined(); + expect(parseFloat(size?.[1] || "")).toBeGreaterThan(0); + expect(size?.[2]).toBe(test.expectedScale); + }); + } + }); + + describe("when not connected", () => { + validateAutoConnectBehavior(integration, "collection-storage-size", () => { + return { + args: { + database: integration.randomDbName(), + collection: "foo", + }, + expectedResponse: `The size of "${integration.randomDbName()}.foo" cannot be determined because the collection does not exist.`, + }; + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/listCollections.test.ts b/tests/integration/tools/mongodb/metadata/listCollections.test.ts index de3ee19b..68183302 100644 --- a/tests/integration/tools/mongodb/metadata/listCollections.test.ts +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -4,6 +4,8 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; @@ -22,22 +24,7 @@ describe("listCollections tool", () => { }); describe("with invalid arguments", () => { - const args = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name: "list-collections", 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 list-collections"); - expect(mcpError.message).toContain('"expected": "string"'); - } - }); - } + validateThrowsForInvalidArguments(integration, "list-collections", dbOperationInvalidArgTests); }); describe("with non-existent database", () => { diff --git a/tests/integration/tools/mongodb/read/count.test.ts b/tests/integration/tools/mongodb/read/count.test.ts index f15b7e7c..bd9412bc 100644 --- a/tests/integration/tools/mongodb/read/count.test.ts +++ b/tests/integration/tools/mongodb/read/count.test.ts @@ -4,6 +4,7 @@ import { setupIntegrationTest, validateToolMetadata, validateAutoConnectBehavior, + validateThrowsForInvalidArguments, } from "../../../helpers.js"; import { toIncludeSameMembers } from "jest-extended"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; @@ -37,27 +38,13 @@ describe("count tool", () => { }); describe("with invalid arguments", () => { - const args = [ + validateThrowsForInvalidArguments(integration, "count", [ {}, { database: 123, collection: "bar" }, { foo: "bar", database: "test", collection: "bar" }, { collection: [], database: "test" }, { collection: "bar", database: "test", query: "{ $gt: { foo: 5 } }" }, - ]; - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name: "count", 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 count"); - } - }); - } + ]); }); it("returns 0 when database doesn't exist", async () => { From e2650725cbf96a09af808e9a9cd4529acab51927 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 23 Apr 2025 14:45:44 +0200 Subject: [PATCH 4/4] PR comments --- tests/integration/helpers.ts | 105 ++++++++++-------- .../mongodb/create/createCollection.test.ts | 32 ++---- .../tools/mongodb/create/createIndex.test.ts | 74 ++++++------ .../tools/mongodb/create/insertMany.test.ts | 65 +++++------ .../tools/mongodb/delete/deleteMany.test.ts | 52 ++++----- .../mongodb/delete/dropCollection.test.ts | 36 +++--- .../tools/mongodb/delete/dropDatabase.test.ts | 35 +++--- .../mongodb/metadata/collectionSchema.test.ts | 38 +++---- .../metadata/collectionStorageSize.test.ts | 36 +++--- .../tools/mongodb/metadata/connect.test.ts | 20 ++-- .../mongodb/metadata/listCollections.test.ts | 40 +++---- .../mongodb/metadata/listDatabases.test.ts | 33 +++--- .../tools/mongodb/read/count.test.ts | 72 +++++------- 13 files changed, 280 insertions(+), 358 deletions(-) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 15c330d6..4a61d672 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -226,20 +226,22 @@ export const dbOperationParameters: ParameterInfo[] = [ export const dbOperationInvalidArgTests = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; -export async function validateToolMetadata( - mcpClient: Client, +export function validateToolMetadata( + integration: IntegrationTest, name: string, description: string, parameters: ParameterInfo[] -): Promise { - const { tools } = await mcpClient.listTools(); - const tool = tools.find((tool) => tool.name === name)!; - expect(tool).toBeDefined(); - expect(tool.description).toBe(description); - - const toolParameters = getParameters(tool); - expect(toolParameters).toHaveLength(parameters.length); - expect(toolParameters).toIncludeAllMembers(parameters); +): void { + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((tool) => tool.name === name)!; + expect(tool).toBeDefined(); + expect(tool.description).toBe(description); + + const toolParameters = getParameters(tool); + expect(toolParameters).toHaveLength(parameters.length); + expect(toolParameters).toIncludeAllMembers(parameters); + }); } export function validateAutoConnectBehavior( @@ -249,35 +251,42 @@ export function validateAutoConnectBehavior( args: { [x: string]: unknown }; expectedResponse?: string; validate?: (content: unknown) => void; - } + }, + beforeEachImpl?: () => Promise ): void { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); + describe("when not connected", () => { + if (beforeEachImpl) { + beforeEach(() => beforeEachImpl()); + } - const validationInfo = validation(); + it("connects automatically if connection string is configured", async () => { + config.connectionString = integration.connectionString(); - const response = await integration.mcpClient().callTool({ - name, - arguments: validationInfo.args, - }); + const validationInfo = validation(); - if (validationInfo.expectedResponse) { - const content = getResponseContent(response.content); - expect(content).toContain(validationInfo.expectedResponse); - } + const response = await integration.mcpClient().callTool({ + name, + arguments: validationInfo.args, + }); - if (validationInfo.validate) { - validationInfo.validate(response.content); - } - }); + if (validationInfo.expectedResponse) { + const content = getResponseContent(response.content); + expect(content).toContain(validationInfo.expectedResponse); + } - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name, - arguments: validation().args, + if (validationInfo.validate) { + validationInfo.validate(response.content); + } + }); + + it("throws an error if connection string is not configured", async () => { + const response = await integration.mcpClient().callTool({ + name, + arguments: validation().args, + }); + const content = getResponseContent(response.content); + expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); }); } @@ -286,20 +295,22 @@ export function validateThrowsForInvalidArguments( name: string, args: { [x: string]: unknown }[] ): void { - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name, 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 ${name}`); - } - }); - } + describe("with invalid arguments", () => { + for (const arg of args) { + it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { + await integration.connectMcpClient(); + try { + await integration.mcpClient().callTool({ name, 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 ${name}`); + } + }); + } + }); } export function describeAtlas(name: number | string | Function | jest.FunctionLike, fn: jest.EmptyFunction) { diff --git a/tests/integration/tools/mongodb/create/createCollection.test.ts b/tests/integration/tools/mongodb/create/createCollection.test.ts index a831e3e9..a03c8ed3 100644 --- a/tests/integration/tools/mongodb/create/createCollection.test.ts +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -7,24 +7,18 @@ import { validateThrowsForInvalidArguments, dbOperationInvalidArgTests, } from "../../../helpers.js"; -import { toIncludeSameMembers } from "jest-extended"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; describe("createCollection tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "create-collection", - "Creates a new collection in a database. If the database doesn't exist, it will be created automatically.", - dbOperationParameters - ); - }); + validateToolMetadata( + integration, + "create-collection", + "Creates a new collection in a database. If the database doesn't exist, it will be created automatically.", + dbOperationParameters + ); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "create-collection", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "create-collection", dbOperationInvalidArgTests); describe("with non-existent database", () => { it("creates a new collection", async () => { @@ -94,12 +88,10 @@ describe("createCollection tool", () => { }); }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "create-collection", () => { - return { - args: { database: integration.randomDbName(), collection: "new-collection" }, - expectedResponse: `Collection "new-collection" created in database "${integration.randomDbName()}".`, - }; - }); + validateAutoConnectBehavior(integration, "create-collection", () => { + return { + args: { database: integration.randomDbName(), collection: "new-collection" }, + expectedResponse: `Collection "new-collection" created in database "${integration.randomDbName()}".`, + }; }); }); diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 09e44004..1dcc1ecd 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -6,41 +6,35 @@ import { validateAutoConnectBehavior, validateThrowsForInvalidArguments, } from "../../../helpers.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { IndexDirection } from "mongodb"; -import config from "../../../../../src/config.js"; describe("createIndex tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata(integration.mcpClient(), "create-index", "Create an index for a collection", [ - ...dbOperationParameters, - { - name: "keys", - type: "object", - description: "The index definition", - required: true, - }, - { - name: "name", - type: "string", - description: "The name of the index", - required: false, - }, - ]); - }); - - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "create-index", [ - {}, - { collection: "bar", database: 123, keys: { foo: 1 } }, - { collection: "bar", database: "test", keys: { foo: 5 } }, - { collection: [], database: "test", keys: { foo: 1 } }, - { collection: "bar", database: "test", keys: { foo: 1 }, name: 123 }, - { collection: "bar", database: "test", keys: "foo", name: "my-index" }, - ]); - }); + validateToolMetadata(integration, "create-index", "Create an index for a collection", [ + ...dbOperationParameters, + { + name: "keys", + type: "object", + description: "The index definition", + required: true, + }, + { + name: "name", + type: "string", + description: "The name of the index", + required: false, + }, + ]); + + validateThrowsForInvalidArguments(integration, "create-index", [ + {}, + { collection: "bar", database: 123, keys: { foo: 1 } }, + { collection: "bar", database: "test", keys: { foo: 5 } }, + { collection: [], database: "test", keys: { foo: 1 } }, + { collection: "bar", database: "test", keys: { foo: 1 }, name: 123 }, + { collection: "bar", database: "test", keys: "foo", name: "my-index" }, + ]); const validateIndex = async (collection: string, expected: { name: string; key: object }[]) => { const mongoClient = integration.mongoClient(); @@ -198,16 +192,14 @@ describe("createIndex tool", () => { }); } - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "create-index", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - keys: { prop1: 1 }, - }, - expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, - }; - }); + validateAutoConnectBehavior(integration, "create-index", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + keys: { prop1: 1 }, + }, + expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, + }; }); }); diff --git a/tests/integration/tools/mongodb/create/insertMany.test.ts b/tests/integration/tools/mongodb/create/insertMany.test.ts index 4ddb94e5..f549fbbc 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -6,39 +6,28 @@ import { validateAutoConnectBehavior, validateThrowsForInvalidArguments, } from "../../../helpers.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; describe("insertMany tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - validateToolMetadata( - integration.mcpClient(), - "insert-many", - "Insert an array of documents into a MongoDB collection", - [ - ...dbOperationParameters, - { - name: "documents", - type: "array", - description: - "The array of documents to insert, matching the syntax of the document argument of db.collection.insertMany()", - required: true, - }, - ] - ); - }); + validateToolMetadata(integration, "insert-many", "Insert an array of documents into a MongoDB collection", [ + ...dbOperationParameters, + { + name: "documents", + type: "array", + description: + "The array of documents to insert, matching the syntax of the document argument of db.collection.insertMany()", + required: true, + }, + ]); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "insert-many", [ - {}, - { collection: "bar", database: 123, documents: [] }, - { collection: [], database: "test", documents: [] }, - { collection: "bar", database: "test", documents: "my-document" }, - { collection: "bar", database: "test", documents: { name: "Peter" } }, - ]); - }); + validateThrowsForInvalidArguments(integration, "insert-many", [ + {}, + { collection: "bar", database: 123, documents: [] }, + { collection: [], database: "test", documents: [] }, + { collection: "bar", database: "test", documents: "my-document" }, + { collection: "bar", database: "test", documents: { name: "Peter" } }, + ]); const validateDocuments = async (collection: string, expectedDocuments: object[]) => { const collections = await integration.mongoClient().db(integration.randomDbName()).listCollections().toArray(); @@ -97,16 +86,14 @@ describe("insertMany tool", () => { expect(content).toContain(insertedIds[0].toString()); }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "insert-many", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - documents: [{ prop1: "value1" }], - }, - expectedResponse: 'Inserted `1` document(s) into collection "coll1"', - }; - }); + validateAutoConnectBehavior(integration, "insert-many", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + documents: [{ prop1: "value1" }], + }, + expectedResponse: 'Inserted `1` document(s) into collection "coll1"', + }; }); }); diff --git a/tests/integration/tools/mongodb/delete/deleteMany.test.ts b/tests/integration/tools/mongodb/delete/deleteMany.test.ts index 0ab5756d..accbe218 100644 --- a/tests/integration/tools/mongodb/delete/deleteMany.test.ts +++ b/tests/integration/tools/mongodb/delete/deleteMany.test.ts @@ -10,23 +10,21 @@ import { describe("deleteMany tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "delete-many", - "Removes all documents that match the filter from a MongoDB collection", - [ - ...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, - }, - ] - ); - }); + validateToolMetadata( + integration, + "delete-many", + "Removes all documents that match the filter from a MongoDB collection", + [ + ...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", () => { validateThrowsForInvalidArguments(integration, "delete-many", [ @@ -145,16 +143,14 @@ describe("deleteMany tool", () => { await validateDocuments([]); }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "delete-many", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - filter: {}, - }, - expectedResponse: 'Deleted `0` document(s) from collection "coll1"', - }; - }); + validateAutoConnectBehavior(integration, "delete-many", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + filter: {}, + }, + expectedResponse: 'Deleted `0` document(s) from collection "coll1"', + }; }); }); diff --git a/tests/integration/tools/mongodb/delete/dropCollection.test.ts b/tests/integration/tools/mongodb/delete/dropCollection.test.ts index 06a50a84..0044231d 100644 --- a/tests/integration/tools/mongodb/delete/dropCollection.test.ts +++ b/tests/integration/tools/mongodb/delete/dropCollection.test.ts @@ -11,18 +11,14 @@ import { describe("dropCollection tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "drop-collection", - "Removes a collection or view from the database. The method also removes any indexes associated with the dropped collection.", - dbOperationParameters - ); - }); + validateToolMetadata( + integration, + "drop-collection", + "Removes a collection or view from the database. The method also removes any indexes associated with the dropped collection.", + dbOperationParameters + ); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "drop-collection", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "drop-collection", dbOperationInvalidArgTests); it("can drop non-existing collection", async () => { await integration.connectMcpClient(); @@ -63,15 +59,13 @@ describe("dropCollection tool", () => { expect(collections[0].name).toBe("coll2"); }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "drop-collection", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - }, - expectedResponse: `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"`, - }; - }); + validateAutoConnectBehavior(integration, "drop-collection", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + }, + expectedResponse: `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"`, + }; }); }); diff --git a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts index ceecd5ab..6ed31afb 100644 --- a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts +++ b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts @@ -11,18 +11,14 @@ import { describe("dropDatabase tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "drop-database", - "Removes the specified database, deleting the associated data files", - [dbOperationParameters.find((d) => d.name === "database")!] - ); - }); + validateToolMetadata( + integration, + "drop-database", + "Removes the specified database, deleting the associated data files", + [dbOperationParameters.find((d) => d.name === "database")!] + ); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "drop-database", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "drop-database", dbOperationInvalidArgTests); it("can drop non-existing database", async () => { let { databases } = await integration.mongoClient().db("").admin().listDatabases(); @@ -70,16 +66,17 @@ describe("dropDatabase tool", () => { expect(collections).toHaveLength(0); }); - describe("when not connected", () => { - beforeEach(async () => { - await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); - }); - - validateAutoConnectBehavior(integration, "drop-database", () => { + validateAutoConnectBehavior( + integration, + "drop-database", + () => { return { args: { database: integration.randomDbName() }, expectedResponse: `Successfully dropped database "${integration.randomDbName()}"`, }; - }); - }); + }, + async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); + } + ); }); diff --git a/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts index 9eda6f90..339dd113 100644 --- a/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts +++ b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts @@ -8,8 +8,6 @@ import { validateThrowsForInvalidArguments, dbOperationInvalidArgTests, } from "../../../helpers.js"; -import { toIncludeSameMembers } from "jest-extended"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { Document } from "bson"; import { OptionalId } from "mongodb"; import { SimplifiedSchema } from "mongodb-schema"; @@ -17,18 +15,14 @@ import { SimplifiedSchema } from "mongodb-schema"; describe("collectionSchema tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "collection-schema", - "Describe the schema for a collection", - dbOperationParameters - ); - }); + validateToolMetadata( + integration, + "collection-schema", + "Describe the schema for a collection", + dbOperationParameters + ); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "collection-schema", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "collection-schema", dbOperationInvalidArgTests); describe("with non-existent database", () => { it("returns empty schema", async () => { @@ -147,15 +141,13 @@ describe("collectionSchema tool", () => { } }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "collection-schema", () => { - return { - args: { - database: integration.randomDbName(), - collection: "new-collection", - }, - expectedResponse: `Could not deduce the schema for "${integration.randomDbName()}.new-collection". This may be because it doesn't exist or is empty.`, - }; - }); + validateAutoConnectBehavior(integration, "collection-schema", () => { + return { + args: { + database: integration.randomDbName(), + collection: "new-collection", + }, + expectedResponse: `Could not deduce the schema for "${integration.randomDbName()}.new-collection". This may be because it doesn't exist or is empty.`, + }; }); }); diff --git a/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts b/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts index f91fff86..4af84030 100644 --- a/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts +++ b/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts @@ -12,18 +12,14 @@ import * as crypto from "crypto"; describe("collectionStorageSize tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "collection-storage-size", - "Gets the size of the collection", - dbOperationParameters - ); - }); + validateToolMetadata( + integration, + "collection-storage-size", + "Gets the size of the collection", + dbOperationParameters + ); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "collection-storage-size", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "collection-storage-size", dbOperationInvalidArgTests); describe("with non-existent database", () => { it("returns 0 MB", async () => { @@ -79,15 +75,13 @@ describe("collectionStorageSize tool", () => { } }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "collection-storage-size", () => { - return { - args: { - database: integration.randomDbName(), - collection: "foo", - }, - expectedResponse: `The size of "${integration.randomDbName()}.foo" cannot be determined because the collection does not exist.`, - }; - }); + validateAutoConnectBehavior(integration, "collection-storage-size", () => { + return { + args: { + database: integration.randomDbName(), + collection: "foo", + }, + expectedResponse: `The size of "${integration.randomDbName()}.foo" cannot be determined because the collection does not exist.`, + }; }); }); diff --git a/tests/integration/tools/mongodb/metadata/connect.test.ts b/tests/integration/tools/mongodb/metadata/connect.test.ts index 0d063b42..d107885d 100644 --- a/tests/integration/tools/mongodb/metadata/connect.test.ts +++ b/tests/integration/tools/mongodb/metadata/connect.test.ts @@ -5,17 +5,15 @@ import config from "../../../../../src/config.js"; describe("Connect tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - validateToolMetadata(integration.mcpClient(), "connect", "Connect to a MongoDB instance", [ - { - name: "options", - description: - "Options for connecting to MongoDB. If not provided, the connection string from the config://connection-string resource will be used. If the user hasn't specified Atlas cluster name or a connection string explicitly and the `config://connection-string` resource is present, always invoke this with no arguments.", - type: "array", - required: false, - }, - ]); - }); + validateToolMetadata(integration, "connect", "Connect to a MongoDB instance", [ + { + name: "options", + description: + "Options for connecting to MongoDB. If not provided, the connection string from the config://connection-string resource will be used. If the user hasn't specified Atlas cluster name or a connection string explicitly and the `config://connection-string` resource is present, always invoke this with no arguments.", + type: "array", + required: false, + }, + ]); describe("with default config", () => { describe("without connection string", () => { diff --git a/tests/integration/tools/mongodb/metadata/listCollections.test.ts b/tests/integration/tools/mongodb/metadata/listCollections.test.ts index 68183302..f6fb9bc0 100644 --- a/tests/integration/tools/mongodb/metadata/listCollections.test.ts +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -7,25 +7,15 @@ import { validateThrowsForInvalidArguments, dbOperationInvalidArgTests, } from "../../../helpers.js"; -import { toIncludeSameMembers } from "jest-extended"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import config from "../../../../../src/config.js"; describe("listCollections tool", () => { const integration = setupIntegrationTest(); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "list-collections", - "List all collections for a given database", - [{ name: "database", description: "Database name", type: "string", required: true }] - ); - }); + validateToolMetadata(integration, "list-collections", "List all collections for a given database", [ + { name: "database", description: "Database name", type: "string", required: true }, + ]); - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "list-collections", dbOperationInvalidArgTests); - }); + validateThrowsForInvalidArguments(integration, "list-collections", dbOperationInvalidArgTests); describe("with non-existent database", () => { it("returns no collections", async () => { @@ -70,17 +60,15 @@ describe("listCollections tool", () => { }); }); - describe("when not connected", () => { - validateAutoConnectBehavior( - integration, - "list-collections", + validateAutoConnectBehavior( + integration, + "list-collections", - () => { - return { - args: { database: integration.randomDbName() }, - expectedResponse: `No collections found for database "${integration.randomDbName()}". To create a collection, use the "create-collection" tool.`, - }; - } - ); - }); + () => { + return { + args: { database: integration.randomDbName() }, + expectedResponse: `No collections found for database "${integration.randomDbName()}". To create a collection, use the "create-collection" tool.`, + }; + } + ); }); diff --git a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts index 7785b067..6d8ee7a3 100644 --- a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts +++ b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts @@ -26,7 +26,7 @@ describe("listDatabases tool", () => { const response = await integration.mcpClient().callTool({ name: "list-databases", arguments: {} }); const dbNames = getDbNames(response.content); - expect(dbNames).toIncludeSameMembers(defaultDatabases); + expect(defaultDatabases).toIncludeAllMembers(defaultDatabases); }); }); @@ -44,28 +44,29 @@ describe("listDatabases tool", () => { }); }); - describe("when not connected", () => { - beforeEach(async () => { - const mongoClient = integration.mongoClient(); - const { databases } = await mongoClient.db("admin").command({ listDatabases: 1, nameOnly: true }); - for (const db of databases) { - if (!defaultDatabases.includes(db.name)) { - await mongoClient.db(db.name).dropDatabase(); - } - } - }); - - validateAutoConnectBehavior(integration, "list-databases", () => { + validateAutoConnectBehavior( + integration, + "list-databases", + () => { return { args: {}, validate: (content) => { const dbNames = getDbNames(content); - expect(dbNames).toIncludeSameMembers(defaultDatabases); + expect(defaultDatabases).toIncludeAllMembers(dbNames); }, }; - }); - }); + }, + async () => { + const mongoClient = integration.mongoClient(); + const { databases } = await mongoClient.db("admin").command({ listDatabases: 1, nameOnly: true }); + for (const db of databases) { + if (!defaultDatabases.includes(db.name)) { + await mongoClient.db(db.name).dropDatabase(); + } + } + } + ); }); function getDbNames(content: unknown): (string | null)[] { diff --git a/tests/integration/tools/mongodb/read/count.test.ts b/tests/integration/tools/mongodb/read/count.test.ts index bd9412bc..869c1ea2 100644 --- a/tests/integration/tools/mongodb/read/count.test.ts +++ b/tests/integration/tools/mongodb/read/count.test.ts @@ -6,46 +6,28 @@ import { validateAutoConnectBehavior, validateThrowsForInvalidArguments, } from "../../../helpers.js"; -import { toIncludeSameMembers } from "jest-extended"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { ObjectId } from "mongodb"; -import config from "../../../../../src/config.js"; describe("count tool", () => { const integration = setupIntegrationTest(); - let randomDbName: string; - beforeEach(() => { - randomDbName = new ObjectId().toString(); - }); + validateToolMetadata(integration, "count", "Gets the number of documents in a MongoDB collection", [ + { + name: "query", + description: + "The query filter to count documents. Matches the syntax of the filter argument of db.collection.count()", + type: "object", + required: false, + }, + ...dbOperationParameters, + ]); - it("should have correct metadata", async () => { - await validateToolMetadata( - integration.mcpClient(), - "count", - "Gets the number of documents in a MongoDB collection", - [ - { - name: "query", - description: - "The query filter to count documents. Matches the syntax of the filter argument of db.collection.count()", - type: "object", - required: false, - }, - ...dbOperationParameters, - ] - ); - }); - - describe("with invalid arguments", () => { - validateThrowsForInvalidArguments(integration, "count", [ - {}, - { database: 123, collection: "bar" }, - { foo: "bar", database: "test", collection: "bar" }, - { collection: [], database: "test" }, - { collection: "bar", database: "test", query: "{ $gt: { foo: 5 } }" }, - ]); - }); + validateThrowsForInvalidArguments(integration, "count", [ + {}, + { database: 123, collection: "bar" }, + { foo: "bar", database: "test", collection: "bar" }, + { collection: [], database: "test" }, + { collection: "bar", database: "test", query: "{ $gt: { foo: 5 } }" }, + ]); it("returns 0 when database doesn't exist", async () => { await integration.connectMcpClient(); @@ -60,10 +42,10 @@ describe("count tool", () => { it("returns 0 when collection doesn't exist", async () => { await integration.connectMcpClient(); const mongoClient = integration.mongoClient(); - await mongoClient.db(randomDbName).collection("bar").insertOne({}); + await mongoClient.db(integration.randomDbName()).collection("bar").insertOne({}); const response = await integration.mcpClient().callTool({ name: "count", - arguments: { database: randomDbName, collection: "non-existent" }, + arguments: { database: integration.randomDbName(), collection: "non-existent" }, }); const content = getResponseContent(response.content); expect(content).toEqual('Found 0 documents in the collection "non-existent"'); @@ -73,7 +55,7 @@ describe("count tool", () => { beforeEach(async () => { const mongoClient = integration.mongoClient(); await mongoClient - .db(randomDbName) + .db(integration.randomDbName()) .collection("foo") .insertMany([ { name: "Peter", age: 5 }, @@ -93,7 +75,7 @@ describe("count tool", () => { await integration.connectMcpClient(); const response = await integration.mcpClient().callTool({ name: "count", - arguments: { database: randomDbName, collection: "foo", query: testCase.filter }, + arguments: { database: integration.randomDbName(), collection: "foo", query: testCase.filter }, }); const content = getResponseContent(response.content); @@ -102,12 +84,10 @@ describe("count tool", () => { } }); - describe("when not connected", () => { - validateAutoConnectBehavior(integration, "count", () => { - return { - args: { database: randomDbName, collection: "coll1" }, - expectedResponse: 'Found 0 documents in the collection "coll1"', - }; - }); + validateAutoConnectBehavior(integration, "count", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1" }, + expectedResponse: 'Found 0 documents in the collection "coll1"', + }; }); });