diff --git a/src/tools/mongodb/metadata/collectionSchema.ts b/src/tools/mongodb/metadata/collectionSchema.ts index b018c843..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 { parseSchema, SchemaField } from "mongodb-schema"; +import { getSimplifiedSchema } 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/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 903ceb70..4a61d672 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; @@ -223,10 +224,93 @@ export const dbOperationParameters: ParameterInfo[] = [ { name: "collection", type: "string", description: "Collection name", required: true }, ]; -export function validateParameters(tool: ToolInfo, parameters: ParameterInfo[]): void { - const toolParameters = getParameters(tool); - expect(toolParameters).toHaveLength(parameters.length); - expect(toolParameters).toIncludeAllMembers(parameters); +export const dbOperationInvalidArgTests = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }]; + +export function validateToolMetadata( + integration: IntegrationTest, + name: string, + description: string, + parameters: ParameterInfo[] +): 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( + integration: IntegrationTest, + name: string, + validation: () => { + args: { [x: string]: unknown }; + expectedResponse?: string; + validate?: (content: unknown) => void; + }, + beforeEachImpl?: () => Promise +): void { + describe("when not connected", () => { + if (beforeEachImpl) { + beforeEach(() => beforeEachImpl()); + } + + 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."); + }); + }); +} + +export function validateThrowsForInvalidArguments( + integration: IntegrationTest, + name: string, + args: { [x: string]: unknown }[] +): void { + 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 042ea7f5..a03c8ed3 100644 --- a/tests/integration/tools/mongodb/create/createCollection.test.ts +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -1,50 +1,24 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, } 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." - ); + validateToolMetadata( + integration, + "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", () => { - 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", () => { it("creates a new collection", async () => { @@ -114,25 +88,10 @@ 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..1dcc1ecd 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -1,63 +1,40 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + 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 () => { - 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, [ - ...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", () => { - const args = [ - {}, - { 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"); - } - }); - } - }); + 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(); @@ -215,35 +192,14 @@ 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: { - 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."); - }); + 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 2b413d27..f549fbbc 100644 --- a/tests/integration/tools/mongodb/create/insertMany.test.ts +++ b/tests/integration/tools/mongodb/create/insertMany.test.ts @@ -1,56 +1,33 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + 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 () => { - 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, - }, - ]); - }); - - describe("with invalid arguments", () => { - const args = [ - {}, - { 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"); - } - }); - } - }); + 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, + }, + ]); + + 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(); @@ -109,33 +86,14 @@ describe("insertMany tool", () => { expect(content).toContain(insertedIds[0].toString()); }); - 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: { - 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."); - }); + 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 2ba7d06a..accbe218 100644 --- a/tests/integration/tools/mongodb/delete/deleteMany.test.ts +++ b/tests/integration/tools/mongodb/delete/deleteMany.test.ts @@ -1,22 +1,20 @@ import { getResponseContent, - validateParameters, dbOperationParameters, 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(); - 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, [ + validateToolMetadata( + integration, + "delete-many", + "Removes all documents that match the filter from a MongoDB collection", + [ ...dbOperationParameters, { name: "filter", @@ -25,31 +23,17 @@ describe("deleteMany tool", () => { "The query filter, specifying the deletion criteria. Matches the syntax of the filter argument of db.collection.deleteMany()", required: false, }, - ]); - }); + ] + ); describe("with invalid arguments", () => { - const args = [ + 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 () => { @@ -159,33 +143,14 @@ describe("deleteMany tool", () => { await validateDocuments([]); }); - describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "delete-many", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - filter: {}, - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain('Deleted `0` document(s) from collection "coll1"'); - }); - - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name: "delete-many", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - filter: {}, - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); - }); + 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 a82152ed..0044231d 100644 --- a/tests/integration/tools/mongodb/delete/dropCollection.test.ts +++ b/tests/integration/tools/mongodb/delete/dropCollection.test.ts @@ -1,48 +1,24 @@ import { getResponseContent, - validateParameters, dbOperationParameters, 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(); - it("should have correct metadata", async () => { - const { tools } = await integration.mcpClient().listTools(); - const dropCollection = tools.find((tool) => tool.name === "drop-collection")!; - expect(dropCollection).toBeDefined(); - expect(dropCollection.description).toBe( - "Removes a collection or view from the database. The method also removes any indexes associated with the dropped collection." - ); - - validateParameters(dropCollection, [...dbOperationParameters]); - }); + 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", () => { - 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 () => { await integration.connectMcpClient(); @@ -83,36 +59,13 @@ describe("dropCollection tool", () => { expect(collections[0].name).toBe("coll2"); }); - describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - await integration.connectMcpClient(); - await integration.mongoClient().db(integration.randomDbName()).createCollection("coll1"); - - config.connectionString = integration.connectionString(); - - const response = await integration.mcpClient().callTool({ - name: "drop-collection", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain( - `Successfully dropped collection "coll1" from database "${integration.randomDbName()}"` - ); - }); - - it("throws an error if connection string is not configured", async () => { - const response = await integration.mcpClient().callTool({ - name: "drop-collection", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("You need to connect to a MongoDB instance before you can access its data."); - }); + 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 80058cf0..6ed31afb 100644 --- a/tests/integration/tools/mongodb/delete/dropDatabase.test.ts +++ b/tests/integration/tools/mongodb/delete/dropDatabase.test.ts @@ -1,41 +1,24 @@ import { getResponseContent, - validateParameters, dbOperationParameters, 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(); - 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"); + validateToolMetadata( + integration, + "drop-database", + "Removes the specified database, deleting the associated data files", + [dbOperationParameters.find((d) => d.name === "database")!] + ); - validateParameters(dropDatabase, [dbOperationParameters.find((d) => d.name === "database")!]); - }); - - describe("with invalid arguments", () => { - const args = [{}, { database: 123 }, { foo: "bar", database: "test" }]; - for (const arg of args) { - it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => { - await integration.connectMcpClient(); - try { - await integration.mcpClient().callTool({ name: "drop-database", arguments: arg }); - expect.fail("Expected an error to be thrown"); - } catch (error) { - expect(error).toBeInstanceOf(McpError); - const mcpError = error as McpError; - expect(mcpError.code).toEqual(-32602); - expect(mcpError.message).toContain("Invalid arguments for tool drop-database"); - } - }); - } - }); + validateThrowsForInvalidArguments(integration, "drop-database", dbOperationInvalidArgTests); it("can drop non-existing database", async () => { let { databases } = await integration.mongoClient().db("").admin().listDatabases(); @@ -83,32 +66,17 @@ describe("dropDatabase tool", () => { expect(collections).toHaveLength(0); }); - describe("when not connected", () => { - it("connects automatically if connection string is configured", async () => { - await integration.connectMcpClient(); + validateAutoConnectBehavior( + integration, + "drop-database", + () => { + return { + args: { database: integration.randomDbName() }, + expectedResponse: `Successfully dropped database "${integration.randomDbName()}"`, + }; + }, + 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."); - }); - }); + } + ); }); 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..339dd113 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts @@ -0,0 +1,153 @@ +import { + getResponseElements, + getResponseContent, + setupIntegrationTest, + dbOperationParameters, + validateToolMetadata, + validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, +} from "../../../helpers.js"; +import { Document } from "bson"; +import { OptionalId } from "mongodb"; +import { SimplifiedSchema } from "mongodb-schema"; + +describe("collectionSchema tool", () => { + const integration = setupIntegrationTest(); + + validateToolMetadata( + integration, + "collection-schema", + "Describe the schema for a collection", + dbOperationParameters + ); + + validateThrowsForInvalidArguments(integration, "collection-schema", dbOperationInvalidArgTests); + + 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); + }); + } + }); + + 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 new file mode 100644 index 00000000..4af84030 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/collectionStorageSize.test.ts @@ -0,0 +1,87 @@ +import { + getResponseContent, + setupIntegrationTest, + dbOperationParameters, + validateToolMetadata, + validateAutoConnectBehavior, + dbOperationInvalidArgTests, + validateThrowsForInvalidArguments, +} from "../../../helpers.js"; +import * as crypto from "crypto"; + +describe("collectionStorageSize tool", () => { + const integration = setupIntegrationTest(); + + validateToolMetadata( + integration, + "collection-storage-size", + "Gets the size of the collection", + dbOperationParameters + ); + + 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); + }); + } + }); + + 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 a62f5e8d..d107885d 100644 --- a/tests/integration/tools/mongodb/metadata/connect.test.ts +++ b/tests/integration/tools/mongodb/metadata/connect.test.ts @@ -1,26 +1,19 @@ -import { getResponseContent, validateParameters, setupIntegrationTest } from "../../../helpers.js"; +import { getResponseContent, setupIntegrationTest, validateToolMetadata } from "../../../helpers.js"; import config from "../../../../../src/config.js"; 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, [ - { - 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 a88599f5..f6fb9bc0 100644 --- a/tests/integration/tools/mongodb/metadata/listCollections.test.ts +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -1,41 +1,21 @@ -import { getResponseElements, getResponseContent, validateParameters, setupIntegrationTest } 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"; +import { + getResponseElements, + getResponseContent, + setupIntegrationTest, + validateToolMetadata, + validateAutoConnectBehavior, + validateThrowsForInvalidArguments, + dbOperationInvalidArgTests, +} from "../../../helpers.js"; 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"); + validateToolMetadata(integration, "list-collections", "List all collections for a given database", [ + { name: "database", description: "Database name", type: "string", required: true }, + ]); - validateParameters(listCollections, [ - { name: "database", description: "Database name", type: "string", required: true }, - ]); - }); - - 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", () => { it("returns no collections", async () => { @@ -46,7 +26,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.' ); }); }); @@ -80,25 +60,15 @@ 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..6d8ee7a3 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(defaultDatabases).toIncludeAllMembers(defaultDatabases); }); }); @@ -52,9 +40,33 @@ 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"]); }); }); + + validateAutoConnectBehavior( + integration, + "list-databases", + () => { + return { + args: {}, + validate: (content) => { + const dbNames = getDbNames(content); + + 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 4fbadf93..869c1ea2 100644 --- a/tests/integration/tools/mongodb/read/count.test.ts +++ b/tests/integration/tools/mongodb/read/count.test.ts @@ -1,63 +1,33 @@ import { getResponseContent, - validateParameters, dbOperationParameters, setupIntegrationTest, + validateToolMetadata, + 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(); - }); - - 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"); + 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, + ]); - 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, - ]); - }); - - describe("with invalid arguments", () => { - const args = [ - {}, - { 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"); - } - }); - } - }); + 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(); @@ -72,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"'); @@ -85,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 }, @@ -105,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); @@ -114,25 +84,10 @@ 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: integration.randomDbName(), collection: "coll1" }, + expectedResponse: 'Found 0 documents in the collection "coll1"', + }; }); });