Skip to content

Commit 09faec9

Browse files
authored
feat: add createCollection tool (#76)
1 parent 8c341bf commit 09faec9

File tree

8 files changed

+322
-24
lines changed

8 files changed

+322
-24
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2+
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
3+
import { OperationType, ToolArgs } from "../../tool.js";
4+
5+
export class CreateCollectionTool extends MongoDBToolBase {
6+
protected name = "create-collection";
7+
protected description =
8+
"Creates a new collection in a database. If the database doesn't exist, it will be created automatically.";
9+
protected argsShape = DbOperationArgs;
10+
11+
protected operationType: OperationType = "create";
12+
13+
protected async execute({ collection, database }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
14+
const provider = await this.ensureConnected();
15+
await provider.createCollection(database, collection);
16+
17+
return {
18+
content: [
19+
{
20+
type: "text",
21+
text: `Collection "${collection}" created in database "${database}".`,
22+
},
23+
],
24+
};
25+
}
26+
}

src/tools/mongodb/metadata/listCollections.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,21 @@ export class ListCollectionsTool extends MongoDBToolBase {
1515
const provider = await this.ensureConnected();
1616
const collections = await provider.listCollections(database);
1717

18+
if (collections.length === 0) {
19+
return {
20+
content: [
21+
{
22+
type: "text",
23+
text: `No collections found for database "${database}". To create a collection, use the "create-collection" tool.`,
24+
},
25+
],
26+
};
27+
}
28+
1829
return {
1930
content: collections.map((collection) => {
2031
return {
21-
text: `Name: ${collection.name}`,
32+
text: `Name: "${collection.name}"`,
2233
type: "text",
2334
};
2435
}),

src/tools/mongodb/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { RenameCollectionTool } from "./update/renameCollection.js";
1919
import { DropDatabaseTool } from "./delete/dropDatabase.js";
2020
import { DropCollectionTool } from "./delete/dropCollection.js";
2121
import { ExplainTool } from "./metadata/explain.js";
22+
import { CreateCollectionTool } from "./create/createCollection.js";
2223

2324
export const MongoDbTools = [
2425
ConnectTool,
@@ -42,4 +43,5 @@ export const MongoDbTools = [
4243
DropDatabaseTool,
4344
DropCollectionTool,
4445
ExplainTool,
46+
CreateCollectionTool,
4547
];

tests/integration/helpers.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import path from "path";
66
import fs from "fs/promises";
77
import { Session } from "../../src/session.js";
88
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9+
import { MongoClient } from "mongodb";
10+
import { toIncludeAllMembers } from "jest-extended";
11+
12+
interface ParameterInfo {
13+
name: string;
14+
type: string;
15+
description: string;
16+
}
17+
18+
type ToolInfo = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
919

1020
export function jestTestMCPClient(): () => Client {
1121
let client: Client | undefined;
@@ -59,8 +69,14 @@ export function jestTestMCPClient(): () => Client {
5969
};
6070
}
6171

62-
export function jestTestCluster(): () => runner.MongoCluster {
72+
export function jestTestCluster(): () => { connectionString: string; getClient: () => MongoClient } {
6373
let cluster: runner.MongoCluster | undefined;
74+
let client: MongoClient | undefined;
75+
76+
afterEach(async () => {
77+
await client?.close();
78+
client = undefined;
79+
});
6480

6581
beforeAll(async function () {
6682
// Downloading Windows executables in CI takes a long time because
@@ -108,7 +124,16 @@ export function jestTestCluster(): () => runner.MongoCluster {
108124
throw new Error("beforeAll() hook not ran yet");
109125
}
110126

111-
return cluster;
127+
return {
128+
connectionString: cluster.connectionString,
129+
getClient: () => {
130+
if (!client) {
131+
client = new MongoClient(cluster!.connectionString);
132+
}
133+
134+
return client;
135+
},
136+
};
112137
};
113138
}
114139

@@ -137,3 +162,30 @@ export async function connect(client: Client, cluster: runner.MongoCluster): Pro
137162
arguments: { connectionStringOrClusterName: cluster.connectionString },
138163
});
139164
}
165+
166+
export function getParameters(tool: ToolInfo): ParameterInfo[] {
167+
expect(tool.inputSchema.type).toBe("object");
168+
expect(tool.inputSchema.properties).toBeDefined();
169+
170+
return Object.entries(tool.inputSchema.properties!)
171+
.sort((a, b) => a[0].localeCompare(b[0]))
172+
.map(([key, value]) => {
173+
expect(value).toHaveProperty("type");
174+
expect(value).toHaveProperty("description");
175+
176+
const typedValue = value as { type: string; description: string };
177+
expect(typeof typedValue.type).toBe("string");
178+
expect(typeof typedValue.description).toBe("string");
179+
return {
180+
name: key,
181+
type: typedValue.type,
182+
description: typedValue.description,
183+
};
184+
});
185+
}
186+
187+
export function validateParameters(tool: ToolInfo, parameters: ParameterInfo[]): void {
188+
const toolParameters = getParameters(tool);
189+
expect(toolParameters).toHaveLength(parameters.length);
190+
expect(toolParameters).toIncludeAllMembers(parameters);
191+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {
2+
connect,
3+
jestTestCluster,
4+
jestTestMCPClient,
5+
getResponseContent,
6+
validateParameters,
7+
} from "../../../helpers.js";
8+
import { toIncludeSameMembers } from "jest-extended";
9+
import { McpError } from "@modelcontextprotocol/sdk/types.js";
10+
import { ObjectId } from "bson";
11+
12+
describe("createCollection tool", () => {
13+
const client = jestTestMCPClient();
14+
const cluster = jestTestCluster();
15+
16+
it("should have correct metadata", async () => {
17+
const { tools } = await client().listTools();
18+
const listCollections = tools.find((tool) => tool.name === "create-collection")!;
19+
expect(listCollections).toBeDefined();
20+
expect(listCollections.description).toBe(
21+
"Creates a new collection in a database. If the database doesn't exist, it will be created automatically."
22+
);
23+
24+
validateParameters(listCollections, [
25+
{
26+
name: "database",
27+
description: "Database name",
28+
type: "string",
29+
},
30+
{
31+
name: "collection",
32+
description: "Collection name",
33+
type: "string",
34+
},
35+
]);
36+
});
37+
38+
describe("with invalid arguments", () => {
39+
const args = [
40+
{},
41+
{ database: 123, collection: "bar" },
42+
{ foo: "bar", database: "test", collection: "bar" },
43+
{ collection: [], database: "test" },
44+
];
45+
for (const arg of args) {
46+
it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => {
47+
await connect(client(), cluster());
48+
try {
49+
await client().callTool({ name: "create-collection", arguments: arg });
50+
expect.fail("Expected an error to be thrown");
51+
} catch (error) {
52+
expect(error).toBeInstanceOf(McpError);
53+
const mcpError = error as McpError;
54+
expect(mcpError.code).toEqual(-32602);
55+
expect(mcpError.message).toContain("Invalid arguments for tool create-collection");
56+
}
57+
});
58+
}
59+
});
60+
61+
describe("with non-existent database", () => {
62+
it("creates a new collection", async () => {
63+
const mongoClient = cluster().getClient();
64+
let collections = await mongoClient.db("foo").listCollections().toArray();
65+
expect(collections).toHaveLength(0);
66+
67+
await connect(client(), cluster());
68+
const response = await client().callTool({
69+
name: "create-collection",
70+
arguments: { database: "foo", collection: "bar" },
71+
});
72+
const content = getResponseContent(response.content);
73+
expect(content).toEqual('Collection "bar" created in database "foo".');
74+
75+
collections = await mongoClient.db("foo").listCollections().toArray();
76+
expect(collections).toHaveLength(1);
77+
expect(collections[0].name).toEqual("bar");
78+
});
79+
});
80+
81+
describe("with existing database", () => {
82+
let dbName: string;
83+
beforeEach(() => {
84+
dbName = new ObjectId().toString();
85+
});
86+
87+
it("creates new collection", async () => {
88+
const mongoClient = cluster().getClient();
89+
await mongoClient.db(dbName).createCollection("collection1");
90+
let collections = await mongoClient.db(dbName).listCollections().toArray();
91+
expect(collections).toHaveLength(1);
92+
93+
await connect(client(), cluster());
94+
const response = await client().callTool({
95+
name: "create-collection",
96+
arguments: { database: dbName, collection: "collection2" },
97+
});
98+
const content = getResponseContent(response.content);
99+
expect(content).toEqual(`Collection "collection2" created in database "${dbName}".`);
100+
collections = await mongoClient.db(dbName).listCollections().toArray();
101+
expect(collections).toHaveLength(2);
102+
expect(collections.map((c) => c.name)).toIncludeSameMembers(["collection1", "collection2"]);
103+
});
104+
105+
it("does nothing if collection already exists", async () => {
106+
const mongoClient = cluster().getClient();
107+
await mongoClient.db(dbName).collection("collection1").insertOne({});
108+
let collections = await mongoClient.db(dbName).listCollections().toArray();
109+
expect(collections).toHaveLength(1);
110+
let documents = await mongoClient.db(dbName).collection("collection1").find({}).toArray();
111+
expect(documents).toHaveLength(1);
112+
113+
await connect(client(), cluster());
114+
const response = await client().callTool({
115+
name: "create-collection",
116+
arguments: { database: dbName, collection: "collection1" },
117+
});
118+
const content = getResponseContent(response.content);
119+
expect(content).toEqual(`Collection "collection1" created in database "${dbName}".`);
120+
collections = await mongoClient.db(dbName).listCollections().toArray();
121+
expect(collections).toHaveLength(1);
122+
expect(collections[0].name).toEqual("collection1");
123+
124+
// Make sure we didn't drop the existing collection
125+
documents = await mongoClient.db(dbName).collection("collection1").find({}).toArray();
126+
expect(documents).toHaveLength(1);
127+
});
128+
});
129+
});

tests/integration/tools/mongodb/metadata/connect.test.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getResponseContent, jestTestMCPClient, jestTestCluster } from "../../../helpers.js";
1+
import { getResponseContent, jestTestMCPClient, jestTestCluster, validateParameters } from "../../../helpers.js";
22

33
import config from "../../../../../src/config.js";
44

@@ -11,20 +11,14 @@ describe("Connect tool", () => {
1111
const connectTool = tools.find((tool) => tool.name === "connect")!;
1212
expect(connectTool).toBeDefined();
1313
expect(connectTool.description).toBe("Connect to a MongoDB instance");
14-
expect(connectTool.inputSchema.type).toBe("object");
15-
expect(connectTool.inputSchema.properties).toBeDefined();
1614

17-
const propertyNames = Object.keys(connectTool.inputSchema.properties!);
18-
expect(propertyNames).toHaveLength(1);
19-
expect(propertyNames[0]).toBe("connectionStringOrClusterName");
20-
21-
const connectionStringOrClusterNameProp = connectTool.inputSchema.properties![propertyNames[0]] as {
22-
type: string;
23-
description: string;
24-
};
25-
expect(connectionStringOrClusterNameProp.type).toBe("string");
26-
expect(connectionStringOrClusterNameProp.description).toContain("MongoDB connection string");
27-
expect(connectionStringOrClusterNameProp.description).toContain("cluster name");
15+
validateParameters(connectTool, [
16+
{
17+
name: "connectionStringOrClusterName",
18+
description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format) or cluster name",
19+
type: "string",
20+
},
21+
]);
2822
});
2923

3024
describe("with default config", () => {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
getResponseElements,
3+
connect,
4+
jestTestCluster,
5+
jestTestMCPClient,
6+
getResponseContent,
7+
getParameters,
8+
validateParameters,
9+
} from "../../../helpers.js";
10+
import { toIncludeSameMembers } from "jest-extended";
11+
import { McpError } from "@modelcontextprotocol/sdk/types.js";
12+
13+
describe("listCollections tool", () => {
14+
const client = jestTestMCPClient();
15+
const cluster = jestTestCluster();
16+
17+
it("should have correct metadata", async () => {
18+
const { tools } = await client().listTools();
19+
const listCollections = tools.find((tool) => tool.name === "list-collections")!;
20+
expect(listCollections).toBeDefined();
21+
expect(listCollections.description).toBe("List all collections for a given database");
22+
23+
validateParameters(listCollections, [{ name: "database", description: "Database name", type: "string" }]);
24+
});
25+
26+
describe("with invalid arguments", () => {
27+
const args = [{}, { database: 123 }, { foo: "bar", database: "test" }, { database: [] }];
28+
for (const arg of args) {
29+
it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => {
30+
await connect(client(), cluster());
31+
try {
32+
await client().callTool({ name: "list-collections", arguments: arg });
33+
expect.fail("Expected an error to be thrown");
34+
} catch (error) {
35+
expect(error).toBeInstanceOf(McpError);
36+
const mcpError = error as McpError;
37+
expect(mcpError.code).toEqual(-32602);
38+
expect(mcpError.message).toContain("Invalid arguments for tool list-collections");
39+
expect(mcpError.message).toContain('"expected": "string"');
40+
}
41+
});
42+
}
43+
});
44+
45+
describe("with non-existent database", () => {
46+
it("returns no collections", async () => {
47+
await connect(client(), cluster());
48+
const response = await client().callTool({
49+
name: "list-collections",
50+
arguments: { database: "non-existent" },
51+
});
52+
const content = getResponseContent(response.content);
53+
expect(content).toEqual(
54+
`No collections found for database "non-existent". To create a collection, use the "create-collection" tool.`
55+
);
56+
});
57+
});
58+
59+
describe("with existing database", () => {
60+
it("returns collections", async () => {
61+
const mongoClient = cluster().getClient();
62+
await mongoClient.db("my-db").createCollection("collection-1");
63+
64+
await connect(client(), cluster());
65+
const response = await client().callTool({
66+
name: "list-collections",
67+
arguments: { database: "my-db" },
68+
});
69+
const items = getResponseElements(response.content);
70+
expect(items).toHaveLength(1);
71+
expect(items[0].text).toContain('Name: "collection-1"');
72+
73+
await mongoClient.db("my-db").createCollection("collection-2");
74+
75+
const response2 = await client().callTool({
76+
name: "list-collections",
77+
arguments: { database: "my-db" },
78+
});
79+
const items2 = getResponseElements(response2.content);
80+
expect(items2).toHaveLength(2);
81+
expect(items2.map((item) => item.text)).toIncludeSameMembers([
82+
'Name: "collection-1"',
83+
'Name: "collection-2"',
84+
]);
85+
});
86+
});
87+
});

0 commit comments

Comments
 (0)