From aa4b29093d522bac02e531a28bb834e26fae2333 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 25 Apr 2025 18:37:13 +0100 Subject: [PATCH 01/13] feat: add atlas-connect-cluster tool --- src/tools/atlas/metadata/connectCluster.ts | 89 +++++++++++++++++++ src/tools/atlas/tools.ts | 2 + src/tools/mongodb/mongodbTool.ts | 17 ---- src/tools/tool.ts | 18 ++++ .../integration/tools/atlas/clusters.test.ts | 58 ++++++++++++ 5 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 src/tools/atlas/metadata/connectCluster.ts diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts new file mode 100644 index 000000000..7a5e2d03b --- /dev/null +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { AtlasToolBase } from "../atlasTool.js"; +import { ToolArgs, OperationType } from "../../tool.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): Promise { + 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(), + } + }); + + setTimeout(async () => { // disconnect after 12 hours + if (this.session.serviceProvider) { + await this.session.serviceProvider?.close(true); + this.session.serviceProvider = undefined; + } + }, expiryMs); + + 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}"`, + }, + ] + }; + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index d8018dfba..7e056c978 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -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, @@ -20,4 +21,5 @@ export const AtlasTools = [ CreateDBUserTool, CreateProjectTool, ListOrganizationsTool, + ConnectClusterTool ]; diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index d818c7ab8..a80469e99 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -46,21 +46,4 @@ export abstract class MongoDBToolBase extends ToolBase { return super.handleError(error, args); } - - protected async connectToMongoDB(connectionString: string): Promise { - 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; - } } diff --git a/src/tools/tool.ts b/src/tools/tool.ts index a37c72240..136c1f101 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -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 = z.objectOutputType; @@ -123,4 +124,21 @@ export abstract class ToolBase { ], }; } + + protected async connectToMongoDB(connectionString: string): Promise { + 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; + } } diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index b3bae9792..8d36de9e3 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -29,6 +29,24 @@ 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; @@ -117,5 +135,45 @@ 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"); + const cluster = await integration.mcpServer().session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName: clusterName, + }, + } + }); + + console.log("Cluster connection string: ", cluster?.connectionStrings?.standardSrv || cluster?.connectionStrings?.standard); + }); + + 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}"`); + }); + }); }); }); From 308e195ad0ff6ddc3b4e16200315f5caaf37681a Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 25 Apr 2025 18:42:04 +0100 Subject: [PATCH 02/13] fix: styles --- src/tools/atlas/metadata/connectCluster.ts | 20 ++++++++++++------- src/tools/atlas/tools.ts | 2 +- .../integration/tools/atlas/clusters.test.ts | 8 +++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts index 7a5e2d03b..1543bc415 100644 --- a/src/tools/atlas/metadata/connectCluster.ts +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -3,7 +3,8 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "../atlasTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; -function generateSecurePassword(): string { // TODO: use a better password generator +function generateSecurePassword(): string { + // TODO: use a better password generator return `pwdMcp${Math.floor(Math.random() * 100000)}`; } @@ -44,7 +45,7 @@ export class ConnectClusterTool extends AtlasToolBase { params: { path: { groupId: projectId, - } + }, }, body: { databaseName: "admin", @@ -55,7 +56,7 @@ export class ConnectClusterTool extends AtlasToolBase { databaseName: "admin", }, ], - scopes: [{type: "CLUSTER", name: clusterName}], + scopes: [{ type: "CLUSTER", name: clusterName }], username, password, awsIAMType: "NONE", @@ -63,17 +64,22 @@ export class ConnectClusterTool extends AtlasToolBase { oidcAuthType: "NONE", x509Type: "NONE", deleteAfterDate: expiryDate.toISOString(), - } + }, }); - setTimeout(async () => { // disconnect after 12 hours + setTimeout(async () => { + // disconnect after 12 hours if (this.session.serviceProvider) { await this.session.serviceProvider?.close(true); this.session.serviceProvider = undefined; } }, expiryMs); - const connectionString = (cluster.connectionStrings.standardSrv || cluster.connectionStrings.standard || "").replace('://', `://${username}:${password}@`) + `?authSource=admin`; + const connectionString = + (cluster.connectionStrings.standardSrv || cluster.connectionStrings.standard || "").replace( + "://", + `://${username}:${password}@` + ) + `?authSource=admin`; await this.connectToMongoDB(connectionString); @@ -83,7 +89,7 @@ export class ConnectClusterTool extends AtlasToolBase { type: "text", text: `Connected to cluster "${clusterName}"`, }, - ] + ], }; } } diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index 7e056c978..6ba21a4e8 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -21,5 +21,5 @@ export const AtlasTools = [ CreateDBUserTool, CreateProjectTool, ListOrganizationsTool, - ConnectClusterTool + ConnectClusterTool, ]; diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 8d36de9e3..77cd6d553 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -46,7 +46,6 @@ async function waitClusterState(session: Session, projectId: string, clusterName } } - describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId }) => { const clusterName = "ClusterTest-" + randomId; @@ -146,10 +145,13 @@ describeWithAtlas("clusters", (integration) => { groupId: projectId, clusterName: clusterName, }, - } + }, }); - console.log("Cluster connection string: ", cluster?.connectionStrings?.standardSrv || cluster?.connectionStrings?.standard); + console.log( + "Cluster connection string: ", + cluster?.connectionStrings?.standardSrv || cluster?.connectionStrings?.standard + ); }); it("should have correct metadata", async () => { From fe3ea137034940e363a4135c86aa4f93f5fd6b40 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 25 Apr 2025 18:42:49 +0100 Subject: [PATCH 03/13] fix: docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8c6255661..c600719c3 100644 --- a/README.md +++ b/README.md @@ -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 From a1fcd6533a4f6f011052ff8a3ab218fd61ef53e9 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 25 Apr 2025 19:50:02 +0100 Subject: [PATCH 04/13] fix: enable ips --- tests/integration/tools/atlas/clusters.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 77cd6d553..399d759bb 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -139,19 +139,17 @@ describeWithAtlas("clusters", (integration) => { beforeAll(async () => { const projectId = getProjectId(); await waitClusterState(integration.mcpServer().session, projectId, clusterName, "IDLE"); - const cluster = await integration.mcpServer().session.apiClient.getCluster({ + await integration.mcpServer().session.apiClient.createProjectIpAccessList({ params: { path: { groupId: projectId, - clusterName: clusterName, }, }, + body: [{ + comment: "MCP test", + cidrBlock: "0.0.0.0/0" + }] }); - - console.log( - "Cluster connection string: ", - cluster?.connectionStrings?.standardSrv || cluster?.connectionStrings?.standard - ); }); it("should have correct metadata", async () => { From 61df0dfbc8209d7c2667e6ef7223ad259de6ffee Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 25 Apr 2025 19:59:32 +0100 Subject: [PATCH 05/13] fix: styles --- src/common/utils.ts | 3 +++ src/tools/atlas/metadata/connectCluster.ts | 7 ++++--- tests/integration/tools/atlas/atlasHelpers.ts | 4 ---- tests/integration/tools/atlas/clusters.test.ts | 13 ++++++++----- 4 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 src/common/utils.ts diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 000000000..38caca0c1 --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts index 1543bc415..629710e3e 100644 --- a/src/tools/atlas/metadata/connectCluster.ts +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -2,6 +2,7 @@ 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 @@ -67,13 +68,13 @@ export class ConnectClusterTool extends AtlasToolBase { }, }); - setTimeout(async () => { + void sleep(expiryMs).then(async () => { // disconnect after 12 hours if (this.session.serviceProvider) { - await this.session.serviceProvider?.close(true); + await this.session.serviceProvider.close(true); this.session.serviceProvider = undefined; } - }, expiryMs); + }); const connectionString = (cluster.connectionStrings.standardSrv || cluster.connectionStrings.standard || "").replace( diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index f015b2b24..0c417af81 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -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(); diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 399d759bb..d527643a7 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -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({ @@ -145,10 +146,12 @@ describeWithAtlas("clusters", (integration) => { groupId: projectId, }, }, - body: [{ - comment: "MCP test", - cidrBlock: "0.0.0.0/0" - }] + body: [ + { + comment: "MCP test", + cidrBlock: "0.0.0.0/0", + }, + ], }); }); From 6f2ab2275a0572987ba817d9ba744fc179e59d93 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 11:45:00 +0100 Subject: [PATCH 06/13] fix: make ip changes smooth --- src/tools/atlas/create/createFreeCluster.ts | 5 ++++- tests/integration/tools/atlas/clusters.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tools/atlas/create/createFreeCluster.ts b/src/tools/atlas/create/createFreeCluster.ts index 4dbfff89f..2d93ae801 100644 --- a/src/tools/atlas/create/createFreeCluster.ts +++ b/src/tools/atlas/create/createFreeCluster.ts @@ -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.` }, + ], }; } } diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index d527643a7..dd4fe13aa 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -84,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"); }); }); From aea39a9eda486b7f42d00c0e293ab2ac79552a7b Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 14:03:11 +0100 Subject: [PATCH 07/13] fix: delete currently connected user --- src/common/utils.ts | 3 -- src/errors.ts | 2 + src/session.ts | 47 ++++++++++++++++-- src/tools/atlas/metadata/connectCluster.ts | 48 +++++++++++-------- .../integration/tools/atlas/clusters.test.ts | 11 +++-- 5 files changed, 81 insertions(+), 30 deletions(-) delete mode 100644 src/common/utils.ts diff --git a/src/common/utils.ts b/src/common/utils.ts deleted file mode 100644 index 38caca0c1..000000000 --- a/src/common/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/errors.ts b/src/errors.ts index 224610fbf..79d2fa948 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,8 @@ export enum ErrorCodes { NotConnectedToMongoDB = 1_000_000, InvalidParams = 1_000_001, + CloseServiceProvider = 1_000_007, + DeleteDatabaseUser = 1_000_008, } export class MongoDBError extends Error { diff --git a/src/session.ts b/src/session.ts index 2c5267ce8..212c43dec 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,6 +1,9 @@ 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 from "./logger.js"; +import { mongoLogId } from "mongodb-log-writer"; +import { ErrorCodes } from "./errors.js"; export interface SessionOptions { apiBaseUrl?: string; @@ -16,6 +19,12 @@ export class Session { name: string; version: string; }; + connectedAtlasCluster?: { + username: string; + projectId: string; + clusterName: string; + expiryDate: Date; + }; constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) { const credentials: ApiClientCredentials | undefined = @@ -41,14 +50,46 @@ export class Session { } } - async close(): Promise { + 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( + mongoLogId(ErrorCodes.CloseServiceProvider), + "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)); + + logger.error( + mongoLogId(ErrorCodes.DeleteDatabaseUser), + "atlas-connect-cluster", + `Error deleting previous database user: ${error.message}` + ); + } + this.connectedAtlasCluster = undefined; + } + + async close(): Promise { + await this.disconnect(); } } diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts index 629710e3e..21dd5f277 100644 --- a/src/tools/atlas/metadata/connectCluster.ts +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -2,11 +2,17 @@ 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"; +import { randomBytes } from "crypto"; +import { promisify } from "util"; -function generateSecurePassword(): string { - // TODO: use a better password generator - return `pwdMcp${Math.floor(Math.random() * 100000)}`; +const EXPIRY_MS = 1000 * 60 * 60 * 12; // 12 hours + +const randomBytesAsync = promisify(randomBytes); + +async function generateSecurePassword(): Promise { + const buf = await randomBytesAsync(16); + const pass = buf.toString("base64url"); + return pass; } export class ConnectClusterTool extends AtlasToolBase { @@ -19,6 +25,8 @@ export class ConnectClusterTool extends AtlasToolBase { }; protected async execute({ projectId, clusterName }: ToolArgs): Promise { + await this.session.disconnect(); + const cluster = await this.session.apiClient.getCluster({ params: { path: { @@ -32,15 +40,16 @@ export class ConnectClusterTool extends AtlasToolBase { throw new Error("Cluster not found"); } - if (!cluster.connectionStrings?.standardSrv || !cluster.connectionStrings?.standard) { + 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 = generateSecurePassword(); + const password = await generateSecurePassword(); - const expiryMs = 1000 * 60 * 60 * 12; // 12 hours - const expiryDate = new Date(Date.now() + expiryMs); + const expiryDate = new Date(Date.now() + EXPIRY_MS); await this.session.apiClient.createDatabaseUser({ params: { @@ -68,19 +77,18 @@ export class ConnectClusterTool extends AtlasToolBase { }, }); - void sleep(expiryMs).then(async () => { - // disconnect after 12 hours - if (this.session.serviceProvider) { - await this.session.serviceProvider.close(true); - this.session.serviceProvider = undefined; - } - }); + this.session.connectedAtlasCluster = { + username, + projectId, + clusterName, + expiryDate, + }; - const connectionString = - (cluster.connectionStrings.standardSrv || cluster.connectionStrings.standard || "").replace( - "://", - `://${username}:${password}@` - ) + `?authSource=admin`; + const cn = new URL(baseConnectionString); + cn.username = username; + cn.password = password; + cn.searchParams.set("authSource", "admin"); + const connectionString = cn.toString(); await this.connectToMongoDB(connectionString); diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index dd4fe13aa..f9e079438 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -2,14 +2,17 @@ import { Session } from "../../../../src/session.js"; import { expectDefined } from "../../helpers.js"; import { describeWithAtlas, withProject, randomId } from "./atlasHelpers.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { sleep } from "../../../../src/common/utils.js"; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) { await session.apiClient.deleteCluster({ params: { path: { groupId: projectId, - clusterName: clusterName, + clusterName, }, }, }); @@ -19,7 +22,7 @@ async function deleteAndWaitCluster(session: Session, projectId: string, cluster params: { path: { groupId: projectId, - clusterName: clusterName, + clusterName, }, }, }); @@ -36,7 +39,7 @@ async function waitClusterState(session: Session, projectId: string, clusterName params: { path: { groupId: projectId, - clusterName: clusterName, + clusterName, }, }, }); From b756eabb29950063816176b2d6870c0cefae039d Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 14:16:30 +0100 Subject: [PATCH 08/13] fix: error codes --- src/errors.ts | 2 -- src/logger.ts | 2 ++ src/session.ts | 12 +++--------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 6b67107a9..3c523f53f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,8 +1,6 @@ export enum ErrorCodes { NotConnectedToMongoDB = 1_000_000, InvalidParams = 1_000_001, - CloseServiceProvider = 1_000_007, - DeleteDatabaseUser = 1_000_008, MisconfiguredConnectionString = 1_000_001 } diff --git a/src/logger.ts b/src/logger.ts index b14e073a2..534bfb802 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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), @@ -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 { diff --git a/src/session.ts b/src/session.ts index 61d4ddf6d..f474d59f8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,9 +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 from "./logger.js"; -import { mongoLogId } from "mongodb-log-writer"; -import { ErrorCodes } from "./errors.js"; +import logger, { LogId } from "./logger.js"; import EventEmitter from "events"; export interface SessionOptions { @@ -62,11 +60,7 @@ export class Session extends EventEmitter<{ await this.serviceProvider.close(true); } catch (err: unknown) { const error = err instanceof Error ? err : new Error(String(err)); - logger.error( - mongoLogId(ErrorCodes.CloseServiceProvider), - "Error closing service provider:", - error.message - ); + logger.error(LogId.mongodbDisconnectFailure, "Error closing service provider:", error.message); } this.serviceProvider = undefined; } @@ -87,7 +81,7 @@ export class Session extends EventEmitter<{ const error = err instanceof Error ? err : new Error(String(err)); logger.error( - mongoLogId(ErrorCodes.DeleteDatabaseUser), + LogId.atlasDeleteDatabaseUserFailure, "atlas-connect-cluster", `Error deleting previous database user: ${error.message}` ); From 50438944832141092d70aca7d1ae18f94cf7c280 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 14:19:24 +0100 Subject: [PATCH 09/13] fix: error codes --- src/errors.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 3c523f53f..ae91c3a0e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,7 +1,6 @@ export enum ErrorCodes { NotConnectedToMongoDB = 1_000_000, - InvalidParams = 1_000_001, - MisconfiguredConnectionString = 1_000_001 + MisconfiguredConnectionString = 1_000_001, } export class MongoDBError extends Error { From c1bfdcc9f4ab34a78ab63e1a40e14dbc98f065d1 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 14:30:05 +0100 Subject: [PATCH 10/13] fix: access --- src/tools/atlas/metadata/connectCluster.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts index 21dd5f277..b7dc61dc8 100644 --- a/src/tools/atlas/metadata/connectCluster.ts +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -51,6 +51,16 @@ export class ConnectClusterTool extends AtlasToolBase { 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: { @@ -62,7 +72,7 @@ export class ConnectClusterTool extends AtlasToolBase { groupId: projectId, roles: [ { - roleName: "readWriteAnyDatabase", + roleName, databaseName: "admin", }, ], From bd25b0cedc5f10d63db9e8be6af3f242cc7bd082 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Mon, 28 Apr 2025 15:08:41 +0100 Subject: [PATCH 11/13] fix: disconnect --- src/session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/session.ts b/src/session.ts index f474d59f8..64af62e5c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -65,6 +65,7 @@ export class Session extends EventEmitter<{ this.serviceProvider = undefined; } if (!this.connectedAtlasCluster) { + this.emit("disconnect"); return; } try { From 46a34df1999be5040bf81fd6f108fbd4eb8c4074 Mon Sep 17 00:00:00 2001 From: Filipe Constantinov Menezes Date: Mon, 28 Apr 2025 17:02:49 +0100 Subject: [PATCH 12/13] Update src/tools/atlas/metadata/connectCluster.ts Co-authored-by: Gagik Amaryan --- src/tools/atlas/metadata/connectCluster.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/atlas/metadata/connectCluster.ts b/src/tools/atlas/metadata/connectCluster.ts index ec8440fca..523226bab 100644 --- a/src/tools/atlas/metadata/connectCluster.ts +++ b/src/tools/atlas/metadata/connectCluster.ts @@ -46,7 +46,7 @@ export class ConnectClusterTool extends AtlasToolBase { throw new Error("Connection string not available"); } - const username = `usrMcp${Math.floor(Math.random() * 100000)}`; + const username = `mcpUser${Math.floor(Math.random() * 100000)}`; const password = await generateSecurePassword(); const expiryDate = new Date(Date.now() + EXPIRY_MS); From 19c2db94850833568a685306bb74856120e46626 Mon Sep 17 00:00:00 2001 From: Filipe Constantinov Menezes Date: Mon, 28 Apr 2025 17:03:01 +0100 Subject: [PATCH 13/13] Update src/session.ts Co-authored-by: Gagik Amaryan --- src/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index a5d7ad3d6..570536889 100644 --- a/src/session.ts +++ b/src/session.ts @@ -55,7 +55,7 @@ export class Session extends EventEmitter<{ } } - async disconnect() { + async disconnect(): Promise { if (this.serviceProvider) { try { await this.serviceProvider.close(true);