Skip to content

add create project tool #82

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 8 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
32 changes: 32 additions & 0 deletions src/tools/atlas/createProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasToolBase } from "./atlasTool.js";
import { ToolArgs, OperationType } from "../tool.js";
import { Group } from "../../common/atlas/openapi.js";

export class CreateProjectTool extends AtlasToolBase {
protected name = "atlas-create-project";
protected description = "Create a MongoDB Atlas project";
protected operationType: OperationType = "create";
protected argsShape = {
projectName: z.string().describe("Name for the new project"),
organizationId: z.string().describe("Organization ID for the new project"),
Copy link
Collaborator

Choose a reason for hiding this comment

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

When testing, have you noticed models be consistently good about discovering the correct org id or is this something users will be prompted to fill out?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no, this one is a bit more tricky since it involves project creation it can be expected that uses didn't do any operations to their org. but your comment made me realize that we might have room to generate the project name at least, we don't need to prompt for that

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the org id would be quite annoying for people to figure out - is there no way to obtain the org id from the token or some other API? If I'm chatting with an LLM and I need to go open the browser to figure out what my org id is, I might as well go ahead and create my project then and there.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we will need an atlas-list-orgs tool otherwise this is unusable

Copy link
Collaborator

Choose a reason for hiding this comment

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

I wouldn't create a tool to create orgs given service accounts are scoped per orgs

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ok, I'll add list orgs and try

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

added it, however I'm wondering if we should ask the user first if we can create it with the found org id, but i added using assumption

};

protected async execute({ projectName, organizationId }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
this.session.ensureAuthenticated();

const input = {
name: projectName,
orgId: organizationId,
} as Group;

await this.session.apiClient.createProject({
body: input,
});

return {
content: [{ type: "text", text: `Project "${projectName}" created successfully.` }],
};
}
}
2 changes: 2 additions & 0 deletions src/tools/atlas/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CreateAccessListTool } from "./createAccessList.js";
import { InspectAccessListTool } from "./inspectAccessList.js";
import { ListDBUsersTool } from "./listDBUsers.js";
import { CreateDBUserTool } from "./createDBUser.js";
import { CreateProjectTool } from "./createProject.js";

export const AtlasTools = [
ListClustersTool,
Expand All @@ -16,4 +17,5 @@ export const AtlasTools = [
InspectAccessListTool,
ListDBUsersTool,
CreateDBUserTool,
CreateProjectTool,
];
57 changes: 55 additions & 2 deletions tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import fs from "fs/promises";
import { Session } from "../../src/session.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { MongoClient } from "mongodb";
import { ApiClient } from "../../src/common/atlas/apiClient.js";
import { toIncludeAllMembers } from "jest-extended";
import { Group } from "../../src/common/atlas/openapi.js";

interface ParameterInfo {
name: string;
Expand All @@ -21,6 +23,7 @@ type ToolInfo = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
export function jestTestMCPClient(): () => Client {
let client: Client | undefined;
let server: Server | undefined;
let session: Session | undefined;

beforeEach(async () => {
const clientTransport = new InMemoryTransport();
Expand All @@ -42,13 +45,16 @@ export function jestTestMCPClient(): () => Client {
}
);

session = jestTestSession();

server = new Server({
mcpServer: new McpServer({
name: "test-server",
version: "1.2.3",
}),
session: new Session(),
session,
});

await server.connect(serverTransport);
await client.connect(clientTransport);
});
Expand All @@ -59,13 +65,15 @@ export function jestTestMCPClient(): () => Client {

await server?.close();
server = undefined;

session = undefined;
jest.restoreAllMocks();
});

return () => {
if (!client) {
throw new Error("beforeEach() hook not ran yet");
}

return client;
};
}
Expand Down Expand Up @@ -196,3 +204,48 @@ export function validateParameters(tool: ToolInfo, parameters: ParameterInfo[]):
expect(toolParameters).toHaveLength(parameters.length);
expect(toolParameters).toIncludeAllMembers(parameters);
}

const jestTestAtlasData = {
project: {
id: "test-project-id",
name: "test-project",
orgId: "test-org-id",
clusterCount: 0,
created: new Date().toISOString(),
regionUsageRestrictions: "COMMERCIAL_FEDRAMP_REGIONS_ONLY" as const,
withDefaultAlertsSettings: true,
} satisfies Group,
projects: {
results: [],
totalCount: 0,
},
};

function jestTestAtlasClient(): ApiClient {
const apiClient = new ApiClient({
baseUrl: "http://localhost:3000",
userAgent: "AtlasMCP-Test",
credentials: {
clientId: "test-client-id",
clientSecret: "test-client-secret",
},
});

jest.spyOn(apiClient, "createProject").mockResolvedValue(jestTestAtlasData.project);
jest.spyOn(apiClient, "listProjects").mockResolvedValue(jestTestAtlasData.projects);
jest.spyOn(apiClient, "getProject").mockResolvedValue(jestTestAtlasData.project);

return apiClient;
}

function jestTestSession(): Session {
const session = new Session();
const apiClient = jestTestAtlasClient();

Object.defineProperty(session, "apiClient", {
get: () => apiClient,
configurable: true,
});

return session;
}
70 changes: 70 additions & 0 deletions tests/integration/tools/atlas/createProject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { jestTestMCPClient, validateParameters, getResponseContent } from "../../helpers.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";

describe("createProject tool", () => {
const client = jestTestMCPClient();
// atlas client mockk

it("should have correct metadata", async () => {
const { tools } = await client().listTools();
const createProject = tools.find((tool) => tool.name === "atlas-create-project")!;
expect(createProject).toBeDefined();
expect(createProject.description).toBe("Create a MongoDB Atlas project");

// Validate the parameters match the schema
validateParameters(createProject, [
{
name: "projectName",
type: "string",
description: "Name for the new project",
required: true,
},
{
name: "organizationId",
type: "string",
description: "Organization ID for the new project",
required: true,
},
]);
});

describe("with invalid arguments", () => {
const args = [
{}, // Empty args
{ projectName: 123, organizationId: "org-1" }, // Invalid projectName type
{ projectName: "Test Project" }, // Missing organizationId
{ projectName: "Test Project", organizationId: 456 }, // Invalid organizationId type
{ projectName: "", organizationId: "org-1" }, // Empty projectName
];

for (const arg of args) {
it(`throws a schema error for: ${JSON.stringify(arg)}`, async () => {
try {
await client().callTool({ name: "atlas-create-project", 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 atlas-create-project");
}
});
}
});

describe("with valid arguments", () => {
it("creates a new project", async () => {
const projectName = "Test Project";
const organizationId = "test-org-id";

const response = await client().callTool({
name: "atlas-create-project",
arguments: { projectName, organizationId },
});

expect(response).toBeDefined();
const content = getResponseContent(response.content);
expect(content).toEqual(`Project "${projectName}" created successfully.`);
});
});
});
Loading