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
2 changes: 2 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const LogId = {
serverInitialized: mongoLogId(1_000_002),

atlasCheckCredentials: mongoLogId(1_001_001),
atlasDeleteDatabaseUserFailure: mongoLogId(1_001_002),

telemetryDisabled: mongoLogId(1_002_001),
telemetryEmitFailure: mongoLogId(1_002_002),
Expand All @@ -22,6 +23,7 @@ export const LogId = {
toolDisabled: mongoLogId(1_003_003),

mongodbConnectFailure: mongoLogId(1_004_001),
mongodbDisconnectFailure: mongoLogId(1_004_002),
} as const;

abstract class LoggerBase {
Expand Down
46 changes: 42 additions & 4 deletions src/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ApiClient, ApiClientCredentials } from "./common/atlas/apiClient.js";
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
import logger, { LogId } from "./logger.js";
import EventEmitter from "events";

export interface SessionOptions {
Expand All @@ -11,6 +12,7 @@ export interface SessionOptions {

export class Session extends EventEmitter<{
close: [];
disconnect: [];
}> {
sessionId?: string;
serviceProvider?: NodeDriverServiceProvider;
Expand All @@ -19,6 +21,12 @@ export class Session extends EventEmitter<{
name: string;
version: string;
};
connectedAtlasCluster?: {
username: string;
projectId: string;
clusterName: string;
expiryDate: Date;
};

constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) {
super();
Expand Down Expand Up @@ -46,16 +54,46 @@ export class Session extends EventEmitter<{
}
}

async close(): Promise<void> {
async disconnect() {
if (this.serviceProvider) {
try {
await this.serviceProvider.close(true);
} catch (error) {
console.error("Error closing service provider:", error);
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
logger.error(LogId.mongodbDisconnectFailure, "Error closing service provider:", error.message);
}
this.serviceProvider = undefined;
}
if (!this.connectedAtlasCluster) {
this.emit("disconnect");
return;
}
try {
await this.apiClient.deleteDatabaseUser({
params: {
path: {
groupId: this.connectedAtlasCluster.projectId,
username: this.connectedAtlasCluster.username,
databaseName: "admin",
},
},
});
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));

this.emit("close");
logger.error(
LogId.atlasDeleteDatabaseUserFailure,
"atlas-connect-cluster",
`Error deleting previous database user: ${error.message}`
);
}
this.connectedAtlasCluster = undefined;

this.emit("disconnect");
}

async close(): Promise<void> {
await this.disconnect();
this.emit("close");
}
}
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.` },
],
};
}
}
114 changes: 114 additions & 0 deletions src/tools/atlas/metadata/connectCluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasToolBase } from "../atlasTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { randomBytes } from "crypto";
import { promisify } from "util";

const EXPIRY_MS = 1000 * 60 * 60 * 12; // 12 hours

const randomBytesAsync = promisify(randomBytes);

async function generateSecurePassword(): Promise<string> {
const buf = await randomBytesAsync(16);
const pass = buf.toString("base64url");
return pass;
}

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> {
await this.session.disconnect();

const cluster = await this.session.apiClient.getCluster({
params: {
path: {
groupId: projectId,
clusterName,
},
},
});

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

const baseConnectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard;

if (!baseConnectionString) {
throw new Error("Connection string not available");
}

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

const expiryDate = new Date(Date.now() + EXPIRY_MS);

const readOnly =
this.config.readOnly ||
(this.config.disabledTools?.includes("create") &&
this.config.disabledTools?.includes("update") &&
this.config.disabledTools?.includes("delete") &&
!this.config.disabledTools?.includes("read") &&
!this.config.disabledTools?.includes("metadata"));

const roleName = readOnly ? "readAnyDatabase" : "readWriteAnyDatabase";

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

this.session.connectedAtlasCluster = {
username,
projectId,
clusterName,
expiryDate,
};

const cn = new URL(baseConnectionString);
cn.username = username;
cn.password = password;
cn.searchParams.set("authSource", "admin");
const connectionString = cn.toString();

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 @@ -69,21 +69,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 @@ -6,6 +6,7 @@ import logger, { LogId } from "../logger.js";
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 @@ -150,4 +151,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
Loading
Loading