Skip to content

Commit 3d079c6

Browse files
committed
WIP
1 parent 1848414 commit 3d079c6

File tree

9 files changed

+127
-3
lines changed

9 files changed

+127
-3
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,10 +374,12 @@ To use the Atlas API tools, you'll need to create a service account in MongoDB A
374374
To learn more about Service Accounts, check the [MongoDB Atlas documentation](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/).
375375

376376
2. **Save Client Credentials:**
377+
377378
- After creation, you'll be shown the Client ID and Client Secret
378379
- **Important:** Copy and save the Client Secret immediately as it won't be displayed again
379380

380381
3. **Add Access List Entry:**
382+
381383
- Add your IP address to the API access list
382384

383385
4. **Configure the MCP Server:**
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ApiClientError } from "./apiClientError.js";
2+
3+
/**
4+
* Ensures the current public IP is in the access list for the given Atlas project.
5+
* If the IP is already present, this is a no-op.
6+
* @param apiClient The Atlas API client instance
7+
* @param projectId The Atlas project ID
8+
*/
9+
export async function ensureCurrentIpInAccessList(apiClient: any, projectId: string): Promise<void> {
10+
// Get the current public IP
11+
const { currentIpv4Address } = await apiClient.getIpInfo();
12+
const entry = {
13+
groupId: projectId,
14+
ipAddress: currentIpv4Address,
15+
comment: "Added by MCP pre-run access list helper",
16+
};
17+
try {
18+
await apiClient.createProjectIpAccessList({
19+
params: { path: { groupId: projectId } },
20+
body: [entry],
21+
});
22+
} catch (err) {
23+
if (err instanceof ApiClientError && err.response?.status === 409) {
24+
// 409 Conflict: entry already exists, ignore
25+
return;
26+
}
27+
throw err;
28+
}
29+
}

src/tools/atlas/connect/connectCluster.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ToolArgs, OperationType } from "../../tool.js";
55
import { generateSecurePassword } from "../../../helpers/generatePassword.js";
66
import logger, { LogId } from "../../../common/logger.js";
77
import { inspectCluster } from "../../../common/atlas/cluster.js";
8+
import { ensureCurrentIpInAccessList } from "../../../common/atlas/ensureAccessList.js";
89

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

@@ -198,6 +199,7 @@ export class ConnectClusterTool extends AtlasToolBase {
198199
}
199200

200201
protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
202+
await ensureCurrentIpInAccessList(this.session.apiClient, projectId);
201203
for (let i = 0; i < 60; i++) {
202204
const state = await this.queryConnection(projectId, clusterName);
203205
switch (state) {

src/tools/atlas/create/createDBUser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AtlasToolBase } from "../atlasTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55
import { CloudDatabaseUser, DatabaseUserRole } from "../../../common/atlas/openapi.js";
66
import { generateSecurePassword } from "../../../helpers/generatePassword.js";
7+
import { ensureCurrentIpInAccessList } from "../../../common/atlas/ensureAccessList.js";
78

89
export class CreateDBUserTool extends AtlasToolBase {
910
public name = "atlas-create-db-user";
@@ -44,6 +45,7 @@ export class CreateDBUserTool extends AtlasToolBase {
4445
roles,
4546
clusters,
4647
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
48+
await ensureCurrentIpInAccessList(this.session.apiClient, projectId);
4749
const shouldGeneratePassword = !password;
4850
if (shouldGeneratePassword) {
4951
password = await generateSecurePassword();

src/tools/atlas/create/createFreeCluster.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { AtlasToolBase } from "../atlasTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55
import { ClusterDescription20240805 } from "../../../common/atlas/openapi.js";
6+
import { ensureCurrentIpInAccessList } from "../../../common/atlas/ensureAccessList.js";
67

78
export class CreateFreeClusterTool extends AtlasToolBase {
89
public name = "atlas-create-free-cluster";
@@ -37,6 +38,7 @@ export class CreateFreeClusterTool extends AtlasToolBase {
3738
terminationProtectionEnabled: false,
3839
} as unknown as ClusterDescription20240805;
3940

41+
await ensureCurrentIpInAccessList(this.session.apiClient, projectId);
4042
await this.session.apiClient.createCluster({
4143
params: {
4244
path: {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
import { describeWithAtlas, withProject } from "./atlasHelpers.js";
33
import { expectDefined } from "../../helpers.js";
4+
import { ensureCurrentIpInAccessList } from "../../../../src/common/atlas/ensureAccessList.js";
45

56
function generateRandomIp() {
67
const randomIp: number[] = [192];
@@ -94,5 +95,23 @@ describeWithAtlas("ip access lists", (integration) => {
9495
}
9596
});
9697
});
98+
99+
describe("ensureCurrentIpInAccessList helper", () => {
100+
it("should add the current IP to the access list and be idempotent", async () => {
101+
const apiClient = integration.mcpServer().session.apiClient;
102+
const projectId = getProjectId();
103+
const ipInfo = await apiClient.getIpInfo();
104+
// First call should add the IP
105+
await expect(ensureCurrentIpInAccessList(apiClient, projectId)).resolves.not.toThrow();
106+
// Second call should be a no-op (idempotent)
107+
await expect(ensureCurrentIpInAccessList(apiClient, projectId)).resolves.not.toThrow();
108+
// Check that the IP is present in the access list
109+
const accessList = await apiClient.listProjectIpAccessLists({
110+
params: { path: { groupId: projectId } },
111+
});
112+
const found = accessList.results?.some((entry) => entry.ipAddress === ipInfo.currentIpv4Address);
113+
expect(found).toBe(true);
114+
});
115+
});
97116
});
98117
});

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,20 +81,28 @@ describeWithAtlas("clusters", (integration) => {
8181
expect(createFreeCluster.inputSchema.properties).toHaveProperty("region");
8282
});
8383

84-
it("should create a free cluster", async () => {
84+
it("should create a free cluster and add current IP to access list", async () => {
8585
const projectId = getProjectId();
86+
const session = integration.mcpServer().session;
87+
const ipInfo = await session.apiClient.getIpInfo();
8688

8789
const response = (await integration.mcpClient().callTool({
8890
name: "atlas-create-free-cluster",
8991
arguments: {
9092
projectId,
91-
name: clusterName,
93+
name: clusterName + "-iptest",
9294
region: "US_EAST_1",
9395
},
9496
})) as CallToolResult;
9597
expect(response.content).toBeArray();
96-
expect(response.content).toHaveLength(2);
9798
expect(response.content[0]?.text).toContain("has been created");
99+
100+
// Check that the current IP is present in the access list
101+
const accessList = await session.apiClient.listProjectIpAccessLists({
102+
params: { path: { groupId: projectId } },
103+
});
104+
const found = accessList.results?.some((entry) => entry.ipAddress === ipInfo.currentIpv4Address);
105+
expect(found).toBe(true);
98106
});
99107
});
100108

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ describeWithAtlas("db users", (integration) => {
7878
expect(elements[0]?.text).toContain(userName);
7979
expect(elements[0]?.text).toContain("with password: `");
8080
});
81+
82+
it("should add current IP to access list when creating a database user", async () => {
83+
const projectId = getProjectId();
84+
const session = integration.mcpServer().session;
85+
const ipInfo = await session.apiClient.getIpInfo();
86+
await createUserWithMCP();
87+
const accessList = await session.apiClient.listProjectIpAccessLists({
88+
params: { path: { groupId: projectId } },
89+
});
90+
const found = accessList.results?.some((entry) => entry.ipAddress === ipInfo.currentIpv4Address);
91+
expect(found).toBe(true);
92+
});
8193
});
8294
describe("atlas-list-db-users", () => {
8395
it("should have correct metadata", async () => {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ensureCurrentIpInAccessList } from "../../src/common/atlas/ensureAccessList.js";
2+
import { ApiClientError } from "../../src/common/atlas/apiClientError.js";
3+
4+
describe("ensureCurrentIpInAccessList", () => {
5+
const projectId = "test-project-id";
6+
const ip = "1.2.3.4";
7+
let apiClient: any;
8+
9+
beforeEach(() => {
10+
apiClient = {
11+
getIpInfo: jest.fn().mockResolvedValue({ currentIpv4Address: ip }),
12+
createProjectIpAccessList: jest.fn().mockResolvedValue(undefined),
13+
};
14+
});
15+
16+
it("adds the current IP to the access list", async () => {
17+
await expect(ensureCurrentIpInAccessList(apiClient, projectId)).resolves.not.toThrow();
18+
expect(apiClient.getIpInfo).toHaveBeenCalled();
19+
expect(apiClient.createProjectIpAccessList).toHaveBeenCalledWith({
20+
params: { path: { groupId: projectId } },
21+
body: [
22+
{
23+
groupId: projectId,
24+
ipAddress: ip,
25+
comment: expect.any(String),
26+
},
27+
],
28+
});
29+
});
30+
31+
it("is idempotent if the IP is already present (409 error)", async () => {
32+
apiClient.createProjectIpAccessList.mockRejectedValueOnce(
33+
Object.assign(new ApiClientError("Conflict", { status: 409, statusText: "Conflict" } as any), {
34+
response: { status: 409 },
35+
})
36+
);
37+
await expect(ensureCurrentIpInAccessList(apiClient, projectId)).resolves.not.toThrow();
38+
});
39+
40+
it("throws for other errors", async () => {
41+
apiClient.createProjectIpAccessList.mockRejectedValueOnce(
42+
Object.assign(new ApiClientError("Other", { status: 500, statusText: "Server Error" } as any), {
43+
response: { status: 500 },
44+
})
45+
);
46+
await expect(ensureCurrentIpInAccessList(apiClient, projectId)).rejects.toThrow();
47+
});
48+
});

0 commit comments

Comments
 (0)