Skip to content

feat: add createCollection tool #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/tools/mongodb/create/createCollection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { OperationType, 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: OperationType = "create";

protected async execute({ collection, database }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();
await provider.createCollection(database, collection);

return {
content: [
{
type: "text",
text: `Collection "${collection}" created in database "${database}".`,
},
],
};
}
}
13 changes: 12 additions & 1 deletion src/tools/mongodb/metadata/listCollections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}),
Expand Down
2 changes: 2 additions & 0 deletions src/tools/mongodb/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,4 +43,5 @@ export const MongoDbTools = [
DropDatabaseTool,
DropCollectionTool,
ExplainTool,
CreateCollectionTool,
];
56 changes: 54 additions & 2 deletions tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ 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";
import { toIncludeAllMembers } from "jest-extended";

interface ParameterInfo {
name: string;
type: string;
description: string;
}

type ToolInfo = Awaited<ReturnType<Client["listTools"]>>["tools"][number];

export function jestTestMCPClient(): () => Client {
let client: Client | undefined;
Expand Down Expand Up @@ -59,8 +69,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
Expand Down Expand Up @@ -108,7 +124,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;
},
};
};
}

Expand Down Expand Up @@ -137,3 +162,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);
}
129 changes: 129 additions & 0 deletions tests/integration/tools/mongodb/create/createCollection.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not blocking: this isn't from this PR but naming feels weird in retrospect as jest is an implementation detail. maybe mock or just getTestMCPClient?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it does also call the before/after hooks, so it kind of depends on jest (or a similar testing framework that has the same hooks with similar signatures). Also, I don't think it'd be accurate to call it a mock client as it's more of an integration setup. Happy to iterate on the naming though if we feel this is not the right one.

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);
});
});
});
22 changes: 8 additions & 14 deletions tests/integration/tools/mongodb/metadata/connect.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"',
]);
});
});
});
Loading
Loading