Skip to content

feat: add atlas-connect-cluster tool #131

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 15 commits into from
Apr 28, 2025
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ You may experiment asking `Can you connect to my mongodb instance?`.
- `atlas-list-clusters` - Lists MongoDB Atlas clusters
- `atlas-inspect-cluster` - Inspect a specific MongoDB Atlas cluster
- `atlas-create-free-cluster` - Create a free MongoDB Atlas cluster
- `atlas-connect-cluster` - Connects to MongoDB Atlas cluster
- `atlas-inspect-access-list` - Inspect IP/CIDR ranges with access to MongoDB Atlas clusters
- `atlas-create-access-list` - Configure IP/CIDR access list for MongoDB Atlas clusters
- `atlas-list-db-users` - List MongoDB Atlas database users
Expand Down
3 changes: 3 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
5 changes: 4 additions & 1 deletion src/tools/atlas/create/createFreeCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export class CreateFreeClusterTool extends AtlasToolBase {
});

return {
content: [{ type: "text", text: `Cluster "${name}" has been created in region "${region}".` }],
content: [
{ type: "text", text: `Cluster "${name}" has been created in region "${region}".` },
{ type: "text", text: `Double check your access lists to enable your current IP.` },
],
};
}
}
96 changes: 96 additions & 0 deletions src/tools/atlas/metadata/connectCluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasToolBase } from "../atlasTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { sleep } from "../../../common/utils.js";

function generateSecurePassword(): string {
// TODO: use a better password generator
return `pwdMcp${Math.floor(Math.random() * 100000)}`;
}

export class ConnectClusterTool extends AtlasToolBase {
protected name = "atlas-connect-cluster";
protected description = "Connect to MongoDB Atlas cluster";
protected operationType: OperationType = "metadata";
protected argsShape = {
projectId: z.string().describe("Atlas project ID"),
clusterName: z.string().describe("Atlas cluster name"),
};

protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const cluster = await this.session.apiClient.getCluster({
params: {
path: {
groupId: projectId,
clusterName,
},
},
});

if (!cluster) {
throw new Error("Cluster not found");
}

if (!cluster.connectionStrings?.standardSrv || !cluster.connectionStrings?.standard) {
throw new Error("Connection string not available");
}

const username = `usrMcp${Math.floor(Math.random() * 100000)}`;
const password = generateSecurePassword();

const expiryMs = 1000 * 60 * 60 * 12; // 12 hours
const expiryDate = new Date(Date.now() + expiryMs);

await this.session.apiClient.createDatabaseUser({
params: {
path: {
groupId: projectId,
},
},
body: {
databaseName: "admin",
groupId: projectId,
roles: [
{
roleName: "readWriteAnyDatabase",
databaseName: "admin",
},
],
scopes: [{ type: "CLUSTER", name: clusterName }],
username,
password,
awsIAMType: "NONE",
ldapAuthType: "NONE",
oidcAuthType: "NONE",
x509Type: "NONE",
deleteAfterDate: expiryDate.toISOString(),
},
});

void sleep(expiryMs).then(async () => {
// disconnect after 12 hours
if (this.session.serviceProvider) {
await this.session.serviceProvider.close(true);
this.session.serviceProvider = undefined;
}
});

const connectionString =
(cluster.connectionStrings.standardSrv || cluster.connectionStrings.standard || "").replace(
"://",
`://${username}:${password}@`
) + `?authSource=admin`;

await this.connectToMongoDB(connectionString);

return {
content: [
{
type: "text",
text: `Connected to cluster "${clusterName}"`,
},
],
};
}
}
2 changes: 2 additions & 0 deletions src/tools/atlas/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ListDBUsersTool } from "./read/listDBUsers.js";
import { CreateDBUserTool } from "./create/createDBUser.js";
import { CreateProjectTool } from "./create/createProject.js";
import { ListOrganizationsTool } from "./read/listOrgs.js";
import { ConnectClusterTool } from "./metadata/connectCluster.js";

export const AtlasTools = [
ListClustersTool,
Expand All @@ -20,4 +21,5 @@ export const AtlasTools = [
CreateDBUserTool,
CreateProjectTool,
ListOrganizationsTool,
ConnectClusterTool,
];
17 changes: 0 additions & 17 deletions src/tools/mongodb/mongodbTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,4 @@ export abstract class MongoDBToolBase extends ToolBase {

return super.handleError(error, args);
}

protected async connectToMongoDB(connectionString: string): Promise<void> {
const provider = await NodeDriverServiceProvider.connect(connectionString, {
productDocsLink: "https://docs.mongodb.com/todo-mcp",
productName: "MongoDB MCP",
readConcern: {
level: this.config.connectOptions.readConcern,
},
readPreference: this.config.connectOptions.readPreference,
writeConcern: {
w: this.config.connectOptions.writeConcern,
},
timeoutMS: this.config.connectOptions.timeoutMS,
});

this.session.serviceProvider = provider;
}
}
18 changes: 18 additions & 0 deletions src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mongoLogId } from "mongodb-log-writer";
import { Telemetry } from "../telemetry/telemetry.js";
import { type ToolEvent } from "../telemetry/types.js";
import { UserConfig } from "../config.js";
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";

export type ToolArgs<Args extends ZodRawShape> = z.objectOutputType<Args, ZodNever>;

Expand Down Expand Up @@ -123,4 +124,21 @@ export abstract class ToolBase {
],
};
}

protected async connectToMongoDB(connectionString: string): Promise<void> {
const provider = await NodeDriverServiceProvider.connect(connectionString, {
productDocsLink: "https://docs.mongodb.com/todo-mcp",
productName: "MongoDB MCP",
readConcern: {
level: this.config.connectOptions.readConcern,
},
readPreference: this.config.connectOptions.readPreference,
writeConcern: {
w: this.config.connectOptions.writeConcern,
},
timeoutMS: this.config.connectOptions.timeoutMS,
});

this.session.serviceProvider = provider;
}
}
4 changes: 0 additions & 4 deletions tests/integration/tools/atlas/atlasHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { setupIntegrationTest, IntegrationTest } from "../../helpers.js";

export type IntegrationTestFunction = (integration: IntegrationTest) => void;

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export function describeWithAtlas(name: string, fn: IntegrationTestFunction) {
const testDefinition = () => {
const integration = setupIntegrationTest();
Expand Down
65 changes: 63 additions & 2 deletions tests/integration/tools/atlas/clusters.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Session } from "../../../../src/session.js";
import { expectDefined } from "../../helpers.js";
import { describeWithAtlas, withProject, sleep, randomId } from "./atlasHelpers.js";
import { describeWithAtlas, withProject, randomId } from "./atlasHelpers.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { sleep } from "../../../../src/common/utils.js";

async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) {
await session.apiClient.deleteCluster({
Expand Down Expand Up @@ -29,6 +30,23 @@ async function deleteAndWaitCluster(session: Session, projectId: string, cluster
}
}

async function waitClusterState(session: Session, projectId: string, clusterName: string, state: string) {
while (true) {
const cluster = await session.apiClient.getCluster({
params: {
path: {
groupId: projectId,
clusterName: clusterName,
},
},
});
if (cluster?.stateName === state) {
return;
}
await sleep(1000);
}
}

describeWithAtlas("clusters", (integration) => {
withProject(integration, ({ getProjectId }) => {
const clusterName = "ClusterTest-" + randomId;
Expand Down Expand Up @@ -66,7 +84,7 @@ describeWithAtlas("clusters", (integration) => {
},
})) as CallToolResult;
expect(response.content).toBeArray();
expect(response.content).toHaveLength(1);
expect(response.content).toHaveLength(2);
expect(response.content[0].text).toContain("has been created");
});
});
Expand Down Expand Up @@ -117,5 +135,48 @@ describeWithAtlas("clusters", (integration) => {
expect(response.content[1].text).toContain(`${clusterName} | `);
});
});

describe("atlas-connect-cluster", () => {
beforeAll(async () => {
const projectId = getProjectId();
await waitClusterState(integration.mcpServer().session, projectId, clusterName, "IDLE");
await integration.mcpServer().session.apiClient.createProjectIpAccessList({
params: {
path: {
groupId: projectId,
},
},
body: [
{
comment: "MCP test",
cidrBlock: "0.0.0.0/0",
},
],
});
});

it("should have correct metadata", async () => {
const { tools } = await integration.mcpClient().listTools();
const connectCluster = tools.find((tool) => tool.name === "atlas-connect-cluster");

expectDefined(connectCluster);
expect(connectCluster.inputSchema.type).toBe("object");
expectDefined(connectCluster.inputSchema.properties);
expect(connectCluster.inputSchema.properties).toHaveProperty("projectId");
expect(connectCluster.inputSchema.properties).toHaveProperty("clusterName");
});

it("connects to cluster", async () => {
const projectId = getProjectId();

const response = (await integration.mcpClient().callTool({
name: "atlas-connect-cluster",
arguments: { projectId, clusterName },
})) as CallToolResult;
expect(response.content).toBeArray();
expect(response.content).toHaveLength(1);
expect(response.content[0].text).toContain(`Connected to cluster "${clusterName}"`);
});
});
});
});
Loading