8000 feat: add atlas-connect-cluster tool (#131) · mongodb-js/mongodb-mcp-server@8680e3a · GitHub
[go: up one dir, main page]

Skip to content

Commit 8680e3a

Browse files
authored
feat: add atlas-connect-cluster tool (#131)
1 parent 355fbf2 commit 8680e3a

File tree

8 files changed

+233
-13
lines changed
  • 8 files changed

    +233
    -13
    lines changed

    README.md

    Lines changed: 1 addition & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -104,6 +104,7 @@ You may experiment asking `Can you connect to my mongodb instance?`.
    104104
    - `atlas-list-clusters` - Lists MongoDB Atlas clusters
    105105
    - `atlas-inspect-cluster` - Inspect a specific MongoDB Atlas cluster
    106106
    - `atlas-create-free-cluster` - Create a free MongoDB Atlas cluster
    107+
    - `atlas-connect-cluster` - Connects to MongoDB Atlas cluster
    107108
    - `atlas-inspect-access-list` - Inspect IP/CIDR ranges with access to MongoDB Atlas clusters
    108109
    - `atlas-create-access-list` - Configure IP/CIDR access list for MongoDB Atlas clusters
    109110
    - `atlas-list-db-users` - List MongoDB Atlas database users

    src/logger.ts

    Lines changed: 2 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -11,6 +11,7 @@ export const LogId = {
    1111
    serverInitialized: mongoLogId(1_000_002),
    1212

    1313
    atlasCheckCredentials: mongoLogId(1_001_001),
    14+
    atlasDeleteDatabaseUserFailure: mongoLogId(1_001_002),
    1415

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

    2425
    mongodbConnectFailure: mongoLogId(1_004_001),
    26+
    mongodbDisconnectFailure: mongoLogId(1_004_002),
    2527
    } as const;
    2628

    2729
    abstract class LoggerBase {

    src/session.ts

    Lines changed: 42 additions & 4 deletions
    Original file line numberDiff line numberDiff line change
    @@ -1,6 +1,7 @@
    11
    import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
    22
    import { ApiClient, ApiClientCredentials } from "./common/atlas/apiClient.js";
    33
    import { Implementation } from "@modelcontextprotocol/sdk/types.js";
    4+
    import logger, { LogId } from "./logger.js";
    45
    import EventEmitter from "events";
    56
    import { ConnectOptions } from "./config.js";
    67

    @@ -12,6 +13,7 @@ export interface SessionOptions {
    1213

    1314
    export class Session extends EventEmitter<{
    1415
    close: [];
    16+
    disconnect: [];
    1517
    }> {
    1618
    sessionId?: string;
    1719
    serviceProvider?: NodeDriverServiceProvider;
    @@ -20,6 +22,12 @@ export class Session extends EventEmitter<{
    2022
    name: string;
    2123
    version: string;
    2224
    };
    25+
    connectedAtlasCluster?: {
    26+
    username: string;
    27+
    projectId: string;
    28+
    clusterName: string;
    29+
    expiryDate: Date;
    30+
    };
    2331

    2432
    constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions) {
    2533
    super();
    @@ -47,17 +55,47 @@ export class Session extends EventEmitter<{
    4755
    }
    4856
    }
    4957

    50-
    async close(): Promise<void> {
    58+
    async disconnect(): Promise<void> {
    5159
    if (this.serviceProvider) {
    5260
    try {
    5361
    await this.serviceProvider.close(true);
    54-
    } catch (error) {
    55-
    console.error("Error closing service provider:", error);
    62+
    } catch (err: unknown) {
    63+
    const error = err instanceof Error ? err : new Error(String(err));
    64+
    logger.error(LogId.mongodbDisconnectFailure, "Error closing service provider:", error.message);
    5665
    }
    5766
    this.serviceProvider = undefined;
    67+
    }
    68+
    if (!this.connectedAtlasCluster) {
    69+
    this.emit("disconnect");
    70+
    return;
    71+
    }
    72+
    try {
    73+
    await this.apiClient.deleteDatabaseUser({
    74+
    params: {
    75+
    path: {
    76+
    groupId: this.connectedAtlasCluster.projectId,
    77+
    username: this.connectedAtlasCluster.username,
    78+
    databaseName: "admin",
    79+
    },
    80+
    },
    81+
    });
    82+
    } catch (err: unknown) {
    83+
    const error = err instanceof Error ? err : new Error(String(err));
    5884

    59-
    this.emit("close");
    85+
    logger.error(
    86+
    LogId.atlasDeleteDatabaseUserFailure,
    87+
    "atlas-connect-cluster",
    88+
    `Error deleting previous database user: ${error.message}`
    89+
    );
    6090
    }
    91+
    this.connectedAtlasCluster = undefined;
    92+
    93+
    this.emit("disconnect");
    94+
    }
    95+
    96+
    async close(): Promise<void> {
    97+
    await this.disconnect();
    98+
    this.emit("close");
    6199
    }
    62100

    63101
    async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {

    src/tools/atlas/create/createFreeCluster.ts

    Lines changed: 4 additions & 1 deletion
    Original file line numberDiff line numberDiff line change
    @@ -47,7 +47,10 @@ export class CreateFreeClusterTool extends AtlasToolBase {
    4747
    });
    4848

    4949
    return {
    50-
    content: [{ type: "text", text: `Cluster "${name}" has been created in region "${region}".` }],
    50+
    content: [
    51+
    { type: "text", text: `Cluster "${name}" has been created in region "${region}".` },
    52+
    { type: "text", text: `Double check your access lists to enable your current IP.` },
    53+
    ],
    5154
    };
    5255
    }
    5356
    }
    Lines changed: 114 additions & 0 deletions
    2AE1
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,114 @@
    1+
    import { z } from "zod";
    2+
    import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
    3+
    import { AtlasToolBase } from "../atlasTool.js";
    4+
    import { ToolArgs, OperationType } from "../../tool.js";
    5+
    import { randomBytes } from "crypto";
    6+
    import { promisify } from "util";
    7+
    8+
    const EXPIRY_MS = 1000 * 60 * 60 * 12; // 12 ho 10670 urs
    9+
    10+
    const randomBytesAsync = promisify(randomBytes);
    11+
    12+
    async function generateSecurePassword(): Promise<string> {
    13+
    const buf = await randomBytesAsync(16);
    14+
    const pass = buf.toString("base64url");
    15+
    return pass;
    16+
    }
    17+
    18+
    export class ConnectClusterTool extends AtlasToolBase {
    19+
    protected name = "atlas-connect-cluster";
    20+
    protected description = "Connect to MongoDB Atlas cluster";
    21+
    protected operationType: OperationType = "metadata";
    22+
    protected argsShape = {
    23+
    projectId: z.string().describe("Atlas project ID"),
    24+
    clusterName: z.string().describe("Atlas cluster name"),
    25+
    };
    26+
    27+
    protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
    28+
    await this.session.disconnect();
    29+
    30+
    const cluster = await this.session.apiClient.getCluster({
    31+
    params: {
    32+
    path: {
    33+
    groupId: projectId,
    34+
    clusterName,
    35+
    },
    36+
    },
    37+
    });
    38+
    39+
    if (!cluster) {
    40+
    throw new Error("Cluster not found");
    41+
    }
    42+
    43+
    const baseConnectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard;
    44+
    45+
    if (!baseConnectionString) {
    46+
    throw new Error("Connection string not available");
    47+
    }
    48+
    49+
    const username = `mcpUser${Math.floor(Math.random() * 100000)}`;
    50+
    const password = await generateSecurePassword();
    51+
    52+
    const expiryDate = new Date(Date.now() + EXPIRY_MS);
    53+
    54+
    const readOnly =
    55+
    this.config.readOnly ||
    56+
    (this.config.disabledTools?.includes("create") &&
    57+
    this.config.disabledTools?.includes("update") &&
    58+
    this.config.disabledTools?.includes("delete") &&
    59+
    !this.config.disabledTools?.includes("read") &&
    60+
    !this.config.disabledTools?.includes("metadata"));
    61+
    62+
    const roleName = readOnly ? "readAnyDatabase" : "readWriteAnyDatabase";
    63+
    64+
    await this.session.apiClient.createDatabaseUser({
    65+
    params: {
    66+
    path: {
    67+
    groupId: projectId,
    68+
    },
    69+
    },
    70+
    body: {
    71+
    databaseName: "admin",
    72+
    groupId: projectId,
    73+
    roles: [
    74+
    {
    75+
    roleName,
    76+
    databaseName: "admin",
    77+
    },
    78+
    ],
    79+
    scopes: [{ type: "CLUSTER", name: clusterName }],
    80+
    username,
    81+
    password,
    82+
    awsIAMType: "NONE",
    83+
    ldapAuthType: "NONE",
    84+
    oidcAuthType: "NONE",
    85+
    x509Type: "NONE",
    86+
    deleteAfterDate: expiryDate.toISOString(),
    87+
    },
    88+
    });
    89+
    90+
    this.session.connectedAtlasCluster = {
    91+
    username,
    92+
    projectId,
    93+
    clusterName,
    94+
    expiryDate,
    95+
    };
    96+
    97+
    const cn = new URL(baseConnectionString);
    98+
    cn.username = username;
    99+
    cn.password = password;
    100+
    cn.searchParams.set("authSource", "admin");
    101+
    const connectionString = cn.toString();
    102+
    103+
    await this.session.connectToMongoDB(connectionString, this.config.connectOptions);
    104+
    105+
    return {
    106+
    content: [
    107+
    {
    108+
    type: "text",
    109+
    text: `Connected to cluster "${clusterName}"`,
    110+
    },
    111+
    ],
    112+
    };
    113+
    }
    114+
    }

    src/tools/atlas/tools.ts

    Lines changed: 2 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -8,6 +8,7 @@ import { ListDBUsersTool } from "./read/listDBUsers.js";
    88
    import { CreateDBUserTool } from "./create/createDBUser.js";
    99
    import { CreateProjectTool } from "./create/createProject.js";
    1010
    import { ListOrganizationsTool } from "./read/listOrgs.js";
    11+
    import { ConnectClusterTool } from "./metadata/connectCluster.js";
    1112

    1213
    export const AtlasTools = [
    1314
    ListClustersTool,
    @@ -20,4 +21,5 @@ export const AtlasTools = [
    2021
    CreateDBUserTool,
    2122
    CreateProjectTool,
    2223
    ListOrganizationsTool,
    24+
    ConnectClusterTool,
    2325
    ];

    tests/integration/tools/atlas/atlasHelpers.ts

    Lines changed: 0 additions & 4 deletions
    Original file line numberDiff line numberDiff line change
    @@ -6,10 +6,6 @@ import { config } from "../../../../src/config.js";
    66

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

    9-
    export function sleep(ms: number) {
    10-
    return new Promise((resolve) => setTimeout(resolve, ms));
    11-
    }
    12-
    139
    export function describeWithAtlas(name: string, fn: IntegrationTestFunction) {
    1410
    const testDefinition = () => {
    1511
    const integration = setupIntegrationTest(() => ({

    tests/integration/tools/atlas/clusters.test.ts

    Lines changed: 68 additions & 4 deletions
    Original file line numberDiff line numberDiff line change
    @@ -1,14 +1,18 @@
    11
    import { Session } from "../../../../src/session.js";
    22
    import { expectDefined } from "../../helpers.js";
    3-
    import { describeWithAtlas, withProject, sleep, randomId } from "./atlasHelpers.js";
    3+
    import { describeWithAtlas, withProject, randomId } from "./atlasHelpers.js";
    44
    import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
    55

    6+
    function sleep(ms: number) {
    7+
    return new Promise((resolve) => setTimeout(resolve, ms));
    8+
    }
    9+
    610
    async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string) {
    711
    await session.apiClient.deleteCluster({
    812
    params: {
    913
    path: {
    1014
    groupId: projectId,
    11-
    clusterName: clusterName,
    15+
    clusterName,
    1216
    },
    1317
    },
    1418
    });
    @@ -18,7 +22,7 @@ async function deleteAndWaitCluster(session: Session, projectId: string, cluster
    1822
    params: {
    1923
    path: {
    2024
    groupId: projectId,
    21-
    clusterName: clusterName,
    25+
    clusterName,
    2226
    },
    2327
    },
    2428
    });
    @@ -29,6 +33,23 @@ async function deleteAndWaitCluster(session: Session, projectId: string, cluster
    2933
    }
    3034
    }
    3135

    36+
    async function waitClusterState(session: Session, projectId: string, clusterName: string, state: string) {
    37+
    while (true) {
    38+
    const cluster = await session.apiClient.getCluster({
    39+
    params: {
    40+
    path: {
    41+
    groupId: projectId,
    42+
    clusterName,
    43+
    },
    44+
    },
    45+
    });
    46+
    if (cluster?.stateName === state) {
    47+
    return;
    48+
    }
    49+
    await sleep(1000);
    50+
    }
    51+
    }
    52+
    3253
    describeWithAtlas("clusters", (integration) => {
    3354
    withProject(integration, ({ getProjectId }) => {
    3455
    const clusterName = "ClusterTest-" + randomId;
    @@ -66,7 +87,7 @@ describeWithAtlas("clusters", (integration) => {
    6687
    },
    6788
    })) as CallToolResult;
    6889
    expect(response.content).toBeArray();
    69-
    expect(response.content).toHaveLength(1);
    90+
    expect(response.content).toHaveLength(2);
    7091
    expect(response.content[0].text).toContain("has been created");
    7192
    });
    7293
    });
    @@ -117,5 +138,48 @@ describeWithAtlas("clusters", (integration) => {
    117138
    expect(response.content[1].text).toContain(`${clusterName} | `);
    118139
    });
    119140
    });
    141+
    142+
    describe("atlas-connect-cluster", () => {
    143+
    beforeAll(async () => {
    144+
    const projectId = getProjectId();
    145+
    await waitClusterState(integration.mcpServer().session, projectId, clusterName, "IDLE");
    146+
    await integration.mcpServer().session.apiClient.createProjectIpAccessList({
    147+
    params: {
    148+
    path: {
    149+
    groupId: projectId,
    150+
    },
    151+
    },
    152+
    body: [
    153+
    {
    154+
    comment: "MCP test",
    155+
    cidrBlock: "0.0.0.0/0",
    156+
    },
    157+
    ],
    158+
    });
    159+
    });
    160+
    161+
    it("should have correct metadata", async () => {
    162+
    const { tools } = await integration.mcpClient().listTools();
    163+
    const connectCluster = tools.find((tool) => tool.name === "atlas-connect-cluster");
    164+
    165+
    expectDefined(connectCluster);
    166+
    expect(connectCluster.inputSchema.type).toBe("object");
    167+
    expectDefined(connectCluster.inputSchema.properties);
    168+
    expect(connectCluster.inputSchema.properties).toHaveProperty("projectId");
    169+
    expect(connectCluster.inputSchema.properties).toHaveProperty("clusterName");
    170+
    });
    171+
    172+
    it("connects to cluster", async () => {
    173+
    const projectId = getProjectId();
    174+
    175+
    const response = (await integration.mcpClient().callTool({
    176+
    name: "atlas-connect-cluster",
    177+
    arguments: { projectId, clusterName },
    178+
    })) as CallToolResult;
    179+
    expect(response.content).toBeArray();
    180+
    expect(response.content).toHaveLength(1);
    181+
    expect(response.content[0].text).toContain(`Connected to cluster "${clusterName}"`);
    182+
    });
    183+
    });
    120184
    });
    121185
    });

    0 commit comments

    Comments
     (0)
    0