From bb7be2b85b4fba629e68c556dd7d8337569783d0 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Tue, 15 Apr 2025 09:39:34 +0200 Subject: [PATCH 1/3] feat: add createCollection tool chore: add integration tests for listCollections --- src/tools/mongodb/create/createCollection.ts | 26 ++++ src/tools/mongodb/metadata/listCollections.ts | 13 +- src/tools/mongodb/tools.ts | 2 + tests/integration/helpers.ts | 55 +++++++- .../mongodb/create/createCollection.test.ts | 129 ++++++++++++++++++ .../tools/mongodb/metadata/connect.test.ts | 22 ++- .../mongodb/metadata/listCollections.test.ts | 87 ++++++++++++ .../mongodb/metadata/listDatabases.test.ts | 11 +- 8 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 src/tools/mongodb/create/createCollection.ts create mode 100644 tests/integration/tools/mongodb/create/createCollection.test.ts create mode 100644 tests/integration/tools/mongodb/metadata/listCollections.test.ts diff --git a/src/tools/mongodb/create/createCollection.ts b/src/tools/mongodb/create/createCollection.ts new file mode 100644 index 00000000..e27a76c7 --- /dev/null +++ b/src/tools/mongodb/create/createCollection.ts @@ -0,0 +1,26 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs } from "../../tool.js"; + +export class CreateCollectionTool extends MongoDBToolBase { + protected name = "create-collection"; + protected description = + "Creates a new collection in a database. If the database doesn't exist, it will be created automatically."; + protected argsShape = DbOperationArgs; + + protected operationType: DbOperationType = "create"; + + protected async execute({ collection, database }: ToolArgs): Promise { + const provider = await this.ensureConnected(); + await provider.createCollection(database, collection); + + return { + content: [ + { + type: "text", + text: `Collection "${collection}" created in database "${database}".`, + }, + ], + }; + } +} diff --git a/src/tools/mongodb/metadata/listCollections.ts b/src/tools/mongodb/metadata/listCollections.ts index 09a4f04c..193d0465 100644 --- a/src/tools/mongodb/metadata/listCollections.ts +++ b/src/tools/mongodb/metadata/listCollections.ts @@ -15,10 +15,21 @@ export class ListCollectionsTool extends MongoDBToolBase { const provider = await this.ensureConnected(); const collections = await provider.listCollections(database); + if (collections.length === 0) { + return { + content: [ + { + type: "text", + text: `No collections found for database "${database}". To create a collection, use the "create-collection" tool.`, + }, + ], + }; + } + return { content: collections.map((collection) => { return { - text: `Name: ${collection.name}`, + text: `Name: "${collection.name}"`, type: "text", }; }), diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index d6627e74..ed250832 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -19,6 +19,7 @@ import { RenameCollectionTool } from "./update/renameCollection.js"; import { DropDatabaseTool } from "./delete/dropDatabase.js"; import { DropCollectionTool } from "./delete/dropCollection.js"; import { ExplainTool } from "./metadata/explain.js"; +import { CreateCollectionTool } from "./create/createCollection.js"; export const MongoDbTools = [ ConnectTool, @@ -42,4 +43,5 @@ export const MongoDbTools = [ DropDatabaseTool, DropCollectionTool, ExplainTool, + CreateCollectionTool, ]; diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 8fd3c009..5283b87f 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -6,6 +6,15 @@ import path from "path"; import fs from "fs/promises"; import { Session } from "../../src/session.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { MongoClient } from "mongodb"; + +interface ParameterInfo { + name: string; + type: string; + description: string; +} + +type ToolInfo = Awaited>["tools"][number]; export function jestTestMCPClient(): () => Client { let client: Client | undefined; @@ -59,8 +68,14 @@ export function jestTestMCPClient(): () => Client { }; } -export function jestTestCluster(): () => runner.MongoCluster { +export function jestTestCluster(): () => { connectionString: string; getClient: () => MongoClient } { let cluster: runner.MongoCluster | undefined; + let client: MongoClient | undefined; + + afterEach(async () => { + await client?.close(); + client = undefined; + }); beforeAll(async function () { // Downloading Windows executables in CI takes a long time because @@ -108,7 +123,16 @@ export function jestTestCluster(): () => runner.MongoCluster { throw new Error("beforeAll() hook not ran yet"); } - return cluster; + return { + connectionString: cluster.connectionString, + getClient: () => { + if (!client) { + client = new MongoClient(cluster.connectionString); + } + + return client; + }, + }; }; } @@ -137,3 +161,30 @@ export async function connect(client: Client, cluster: runner.MongoCluster): Pro arguments: { connectionStringOrClusterName: cluster.connectionString }, }); } + +export function getParameters(tool: ToolInfo): ParameterInfo[] { + expect(tool.inputSchema.type).toBe("object"); + expect(tool.inputSchema.properties).toBeDefined(); + + return Object.entries(tool.inputSchema.properties!) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, value]) => { + expect(value).toHaveProperty("type"); + expect(value).toHaveProperty("description"); + + const typedValue = value as { type: string; description: string }; + expect(typeof typedValue.type).toBe("string"); + expect(typeof typedValue.description).toBe("string"); + return { + name: key, + type: typedValue.type, + description: typedValue.description, + }; + }); +} + +export function validateParameters(tool: ToolInfo, parameters: ParameterInfo[]): void { + const toolParameters = getParameters(tool); + expect(toolParameters).toHaveLength(parameters.length); + expect(toolParameters).toIncludeAllMembers(parameters); +} diff --git a/tests/integration/tools/mongodb/create/createCollection.test.ts b/tests/integration/tools/mongodb/create/createCollection.test.ts new file mode 100644 index 00000000..c8031d2c --- /dev/null +++ b/tests/integration/tools/mongodb/create/createCollection.test.ts @@ -0,0 +1,129 @@ +import { + connect, + jestTestCluster, + jestTestMCPClient, + getResponseContent, + validateParameters, +} from "../../../helpers.js"; +import { toIncludeSameMembers } from "jest-extended"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import { ObjectId } from "bson"; + +describe("createCollection tool", () => { + const client = jestTestMCPClient(); + const cluster = jestTestCluster(); + + it("should have correct metadata", async () => { + const { tools } = await client().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." + ); + + validateParameters(listCollections, [ + { + name: "database", + description: "Database name", + type: "string", + }, + { + name: "collection", + description: "Collection name", + type: "string", + }, + ]); + }); + + 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 connect(client(), cluster()); + try { + await client().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"); + } + }); + } + }); + + describe("with non-existent database", () => { + it("creates a new collection", async () => { + const mongoClient = cluster().getClient(); + let collections = await mongoClient.db("foo").listCollections().toArray(); + expect(collections).toHaveLength(0); + + await connect(client(), cluster()); + const response = await client().callTool({ + name: "create-collection", + arguments: { database: "foo", collection: "bar" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual('Collection "bar" created in database "foo".'); + + collections = await mongoClient.db("foo").listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0].name).toEqual("bar"); + }); + }); + + describe("with existing database", () => { + let dbName: string; + beforeEach(() => { + dbName = new ObjectId().toString(); + }); + + it("creates new collection", async () => { + const mongoClient = cluster().getClient(); + await mongoClient.db(dbName).createCollection("collection1"); + let collections = await mongoClient.db(dbName).listCollections().toArray(); + expect(collections).toHaveLength(1); + + await connect(client(), cluster()); + const response = await client().callTool({ + name: "create-collection", + arguments: { database: dbName, collection: "collection2" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual(`Collection "collection2" created in database "${dbName}".`); + collections = await mongoClient.db(dbName).listCollections().toArray(); + expect(collections).toHaveLength(2); + expect(collections.map((c) => c.name)).toIncludeSameMembers(["collection1", "collection2"]); + }); + + it("does nothing if collection already exists", async () => { + const mongoClient = cluster().getClient(); + await mongoClient.db(dbName).collection("collection1").insertOne({}); + let collections = await mongoClient.db(dbName).listCollections().toArray(); + expect(collections).toHaveLength(1); + let documents = await mongoClient.db(dbName).collection("collection1").find({}).toArray(); + expect(documents).toHaveLength(1); + + await connect(client(), cluster()); + const response = await client().callTool({ + name: "create-collection", + arguments: { database: dbName, collection: "collection1" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual(`Collection "collection1" created in database "${dbName}".`); + collections = await mongoClient.db(dbName).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0].name).toEqual("collection1"); + + // Make sure we didn't drop the existing collection + documents = await mongoClient.db(dbName).collection("collection1").find({}).toArray(); + expect(documents).toHaveLength(1); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/connect.test.ts b/tests/integration/tools/mongodb/metadata/connect.test.ts index 64030333..2871eec3 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, jestTestMCPClient, jestTestCluster } from "../../../helpers.js"; +import { getResponseContent, jestTestMCPClient, jestTestCluster, validateParameters } from "../../../helpers.js"; import config from "../../../../../src/config.js"; @@ -11,20 +11,14 @@ describe("Connect tool", () => { const connectTool = tools.find((tool) => tool.name === "connect")!; expect(connectTool).toBeDefined(); expect(connectTool.description).toBe("Connect to a MongoDB instance"); - expect(connectTool.inputSchema.type).toBe("object"); - expect(connectTool.inputSchema.properties).toBeDefined(); - const propertyNames = Object.keys(connectTool.inputSchema.properties!); - expect(propertyNames).toHaveLength(1); - expect(propertyNames[0]).toBe("connectionStringOrClusterName"); - - const connectionStringOrClusterNameProp = connectTool.inputSchema.properties![propertyNames[0]] as { - type: string; - description: string; - }; - expect(connectionStringOrClusterNameProp.type).toBe("string"); - expect(connectionStringOrClusterNameProp.description).toContain("MongoDB connection string"); - expect(connectionStringOrClusterNameProp.description).toContain("cluster name"); + validateParameters(connectTool, [ + { + name: "connectionStringOrClusterName", + description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format) or cluster name", + type: "string", + }, + ]); }); describe("with default config", () => { diff --git a/tests/integration/tools/mongodb/metadata/listCollections.test.ts b/tests/integration/tools/mongodb/metadata/listCollections.test.ts new file mode 100644 index 00000000..6d422513 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/listCollections.test.ts @@ -0,0 +1,87 @@ +import { + getResponseElements, + connect, + jestTestCluster, + jestTestMCPClient, + getResponseContent, + getParameters, + validateParameters, +} from "../../../helpers.js"; +import { toIncludeSameMembers } from "jest-extended"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; + +describe("listCollections tool", () => { + const client = jestTestMCPClient(); + const cluster = jestTestCluster(); + + it("should have correct metadata", async () => { + const { tools } = await client().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" }]); + }); + + 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 connect(client(), cluster()); + try { + await client().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"'); + } + }); + } + }); + + describe("with non-existent database", () => { + it("returns no collections", async () => { + await connect(client(), cluster()); + const response = await client().callTool({ + name: "list-collections", + arguments: { database: "non-existent" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `No collections found for database "non-existent". To create a collection, use the "create-collection" tool.` + ); + }); + }); + + describe("with existing database", () => { + it("returns collections", async () => { + const mongoClient = cluster().getClient(); + await mongoClient.db("my-db").createCollection("collection-1"); + + await connect(client(), cluster()); + const response = await client().callTool({ + name: "list-collections", + arguments: { database: "my-db" }, + }); + const items = getResponseElements(response.content); + expect(items).toHaveLength(1); + expect(items[0].text).toContain('Name: "collection-1"'); + + await mongoClient.db("my-db").createCollection("collection-2"); + + const response2 = await client().callTool({ + name: "list-collections", + arguments: { database: "my-db" }, + }); + const items2 = getResponseElements(response2.content); + expect(items2).toHaveLength(2); + expect(items2.map((item) => item.text)).toIncludeSameMembers([ + 'Name: "collection-1"', + 'Name: "collection-2"', + ]); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts index 153eca13..3cc83fef 100644 --- a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts +++ b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts @@ -1,4 +1,4 @@ -import { getResponseElements, connect, jestTestCluster, jestTestMCPClient } from "../../../helpers.js"; +import { getResponseElements, connect, jestTestCluster, jestTestMCPClient, getParameters } from "../../../helpers.js"; import { MongoClient } from "mongodb"; import { toIncludeSameMembers } from "jest-extended"; @@ -11,11 +11,9 @@ describe("listDatabases tool", () => { const listDatabases = tools.find((tool) => tool.name === "list-databases")!; expect(listDatabases).toBeDefined(); expect(listDatabases.description).toBe("List all databases for a MongoDB connection"); - expect(listDatabases.inputSchema.type).toBe("object"); - expect(listDatabases.inputSchema.properties).toBeDefined(); - const propertyNames = Object.keys(listDatabases.inputSchema.properties!); - expect(propertyNames).toHaveLength(0); + const parameters = getParameters(listDatabases); + expect(parameters).toHaveLength(0); }); describe("with no preexisting databases", () => { @@ -30,10 +28,9 @@ describe("listDatabases tool", () => { describe("with preexisting databases", () => { it("returns their names and sizes", async () => { - const mongoClient = new MongoClient(cluster().connectionString); + const mongoClient = cluster().getClient(); await mongoClient.db("foo").collection("bar").insertOne({ test: "test" }); await mongoClient.db("baz").collection("qux").insertOne({ test: "test" }); - await mongoClient.close(); await connect(client(), cluster()); From 7da5edf531049e7edb9ad72a42fcd035d6e9cea4 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 16 Apr 2025 10:39:58 +0200 Subject: [PATCH 2/3] fixes after rebase --- src/tools/mongodb/create/createCollection.ts | 6 +++--- tests/integration/helpers.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/mongodb/create/createCollection.ts b/src/tools/mongodb/create/createCollection.ts index e27a76c7..27eaa9f5 100644 --- a/src/tools/mongodb/create/createCollection.ts +++ b/src/tools/mongodb/create/createCollection.ts @@ -1,6 +1,6 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "../mongodbTool.js"; -import { ToolArgs } from "../../tool.js"; +import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import { OperationType, ToolArgs } from "../../tool.js"; export class CreateCollectionTool extends MongoDBToolBase { protected name = "create-collection"; @@ -8,7 +8,7 @@ export class CreateCollectionTool extends MongoDBToolBase { "Creates a new collection in a database. If the database doesn't exist, it will be created automatically."; protected argsShape = DbOperationArgs; - protected operationType: DbOperationType = "create"; + protected operationType: OperationType = "create"; protected async execute({ collection, database }: ToolArgs): Promise { const provider = await this.ensureConnected(); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 5283b87f..71d45b09 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -7,6 +7,7 @@ import fs from "fs/promises"; import { Session } from "../../src/session.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { MongoClient } from "mongodb"; +import { toIncludeAllMembers } from "jest-extended"; interface ParameterInfo { name: string; From 8a4cd70eac519f13ab92193c855fcbeb55b7e077 Mon Sep 17 00:00:00 2001 From: Nikola Irinchev Date: Wed, 16 Apr 2025 10:46:26 +0200 Subject: [PATCH 3/3] fix tests --- tests/integration/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 71d45b09..97a4bf0d 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -128,7 +128,7 @@ export function jestTestCluster(): () => { connectionString: string; getClient: connectionString: cluster.connectionString, getClient: () => { if (!client) { - client = new MongoClient(cluster.connectionString); + client = new MongoClient(cluster!.connectionString); } return client;