Skip to content
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: 2 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum ErrorCodes {
NotConnectedToMongoDB = 1_000_000,
MisconfiguredConnectionString = 1_000_001,
InvalidParams = 1_000_001,
MisconfiguredConnectionString = 1_000_001

Check failure on line 4 in src/errors.ts

View workflow job for this annotation

GitHub Actions / check-style

Duplicate enum member value 1000001

Check failure on line 4 in src/errors.ts

View workflow job for this annotation

GitHub Actions / check-style

Insert `,`
}

export class MongoDBError extends Error {
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
45 changes: 41 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,45 @@ 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) {
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.` },
],
};
}
}
104 changes: 104 additions & 0 deletions src/tools/atlas/metadata/connectCluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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);

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(),
},
});

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