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
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 hours
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)