Skip to content
12 changes: 11 additions & 1 deletion src/tools/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const ALLOWED_CLUSTER_NAME_CHARACTERS_ERROR =
const ALLOWED_PROJECT_NAME_CHARACTERS_REGEX = /^[a-zA-Z0-9\s()@&+:._',-]+$/;
export const ALLOWED_PROJECT_NAME_CHARACTERS_ERROR =
"Project names can't be longer than 64 characters and can only contain letters, numbers, spaces, and the following symbols: ( ) @ & + : . _ - ' ,";

export const CommonArgs = {
string: (): ZodString => z.string().regex(NO_UNICODE_REGEX, NO_UNICODE_ERROR),

Expand All @@ -30,7 +31,7 @@ export const CommonArgs = {
};

export const AtlasArgs = {
projectId: (): z.ZodString => CommonArgs.objectId("projectId"),
projectId: (): z.ZodString => CommonArgs.objectId("projectId").describe("Atlas project ID"),

organizationId: (): z.ZodString => CommonArgs.objectId("organizationId"),

Expand Down Expand Up @@ -70,6 +71,15 @@ export const AtlasArgs = {
z.string().min(1, "Password is required").max(100, "Password must be 100 characters or less"),
};

export const ProjectArgs = {
projectId: AtlasArgs.projectId(),
};

export const ProjectAndClusterArgs = {
...ProjectArgs,
clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"),
};

function toEJSON<T extends object | undefined>(value: T): T {
if (!value) {
return value;
Expand Down
5 changes: 2 additions & 3 deletions src/tools/atlas/connect/connectCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { inspectCluster } from "../../../common/atlas/cluster.js";
import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js";
import type { AtlasClusterConnectionInfo } from "../../../common/connectionManager.js";
import { getDefaultRoleFromConfig } from "../../../common/atlas/roles.js";
import { AtlasArgs } from "../../args.js";
import { ProjectAndClusterArgs } from "../../args.js";

const addedIpAccessListMessage =
"Note: Your current IP address has been added to the Atlas project's IP access list to enable secure connection.";
Expand All @@ -20,8 +20,7 @@ function sleep(ms: number): Promise<void> {
}

export const ConnectClusterArgs = {
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"),
...ProjectAndClusterArgs,
};

export class ConnectClusterTool extends AtlasToolBase {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/atlas/create/createAccessList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { type OperationType, type ToolArgs } from "../../tool.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasToolBase } from "../atlasTool.js";
import { makeCurrentIpAccessListEntry, DEFAULT_ACCESS_LIST_COMMENT } from "../../../common/atlas/accessListUtils.js";
import { AtlasArgs, CommonArgs } from "../../args.js";
import { AtlasArgs, CommonArgs, ProjectArgs } from "../../args.js";

export const CreateAccessListArgs = {
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
...ProjectArgs,
ipAddresses: z.array(AtlasArgs.ipAddress()).describe("IP addresses to allow access from").optional(),
cidrBlocks: z.array(AtlasArgs.cidrBlock()).describe("CIDR blocks to allow access from").optional(),
currentIpAddress: z.boolean().describe("Add the current IP address").default(false),
Expand Down
6 changes: 3 additions & 3 deletions src/tools/atlas/create/createDBUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUti
import { AtlasArgs, CommonArgs } from "../../args.js";

export const CreateDBUserArgs = {
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
projectId: AtlasArgs.projectId(),
username: AtlasArgs.username().describe("Username for the new user"),
// Models will generate overly simplistic passwords like SecurePassword123 or
// AtlasPassword123, which are easily guessable and exploitable. We're instructing
Expand All @@ -26,7 +26,7 @@ export const CreateDBUserArgs = {
collectionName: CommonArgs.string().describe("Collection name").optional(),
})
)
.describe("Roles for the new user"),
.describe("Roles for the new database user"),
clusters: z
.array(AtlasArgs.clusterName())
.describe("Clusters to assign the user to, leave empty for access to all clusters")
Expand All @@ -35,7 +35,7 @@ export const CreateDBUserArgs = {

export class CreateDBUserTool extends AtlasToolBase {
public name = "atlas-create-db-user";
protected description = "Create an MongoDB Atlas database user";
protected description = "Create a MongoDB Atlas database user";
public operationType: OperationType = "create";
protected argsShape = {
...CreateDBUserArgs,
Expand Down
15 changes: 9 additions & 6 deletions src/tools/atlas/create/createFreeCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@ import { type ToolArgs, type OperationType } from "../../tool.js";
import { AtlasToolBase } from "../atlasTool.js";
import type { ClusterDescription20240805 } from "../../../common/atlas/openapi.js";
import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js";
import { AtlasArgs } from "../../args.js";
import { ProjectAndClusterArgs, AtlasArgs } from "../../args.js";

export class CreateFreeClusterTool extends AtlasToolBase {
public name = "atlas-create-free-cluster";
protected description = "Create a free MongoDB Atlas cluster";
public operationType: OperationType = "create";
protected argsShape = {
projectId: AtlasArgs.projectId().describe("Atlas project ID to create the cluster in"),
name: AtlasArgs.clusterName().describe("Name of the cluster"),
...ProjectAndClusterArgs,
region: AtlasArgs.region().describe("Region of the cluster").default("US_EAST_1"),
};

protected async execute({ projectId, name, region }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
protected async execute({
projectId,
clusterName,
region,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const input = {
groupId: projectId,
name,
name: clusterName,
clusterType: "REPLICASET",
replicationSpecs: [
{
Expand Down Expand Up @@ -50,7 +53,7 @@ export class CreateFreeClusterTool extends AtlasToolBase {

return {
content: [
{ type: "text", text: `Cluster "${name}" has been created in region "${region}".` },
{ type: "text", text: `Cluster "${clusterName}" has been created in region "${region}".` },
{ type: "text", text: `Double check your access lists to enable your current IP.` },
],
};
Expand Down
2 changes: 1 addition & 1 deletion src/tools/atlas/read/inspectAccessList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AtlasToolBase } from "../atlasTool.js";
import { AtlasArgs } from "../../args.js";

export const InspectAccessListArgs = {
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
projectId: AtlasArgs.projectId(),
};

export class InspectAccessListTool extends AtlasToolBase {
Expand Down
9 changes: 2 additions & 7 deletions src/tools/atlas/read/inspectCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,14 @@ import { type OperationType, type ToolArgs, formatUntrustedData } from "../../to
import { AtlasToolBase } from "../atlasTool.js";
import type { Cluster } from "../../../common/atlas/cluster.js";
import { inspectCluster } from "../../../common/atlas/cluster.js";
import { AtlasArgs } from "../../args.js";

export const InspectClusterArgs = {
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"),
};
import { ProjectAndClusterArgs } from "../../args.js";

export class InspectClusterTool extends AtlasToolBase {
public name = "atlas-inspect-cluster";
protected description = "Inspect MongoDB Atlas cluster";
public operationType: OperationType = "read";
protected argsShape = {
...InspectClusterArgs,
...ProjectAndClusterArgs,
};

protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
Expand Down
59 changes: 58 additions & 1 deletion tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function setupIntegrationTest(
keychain: new Keychain(),
});

// Mock hasValidAccessToken for tests
// Mock API Client for tests
if (!userConfig.apiClientId && !userConfig.apiClientSecret) {
const mockFn = vi.fn().mockResolvedValue(true);
session.apiClient.validateAccessToken = mockFn;
Expand Down Expand Up @@ -242,6 +242,16 @@ export const databaseCollectionParameters: ParameterInfo[] = [
{ name: "collection", type: "string", description: "Collection name", required: true },
];

export const projectIdParameters: ParameterInfo[] = [
{ name: "projectId", type: "string", description: "Atlas project ID", required: true },
];

export const createClusterParameters: ParameterInfo[] = [
{ name: "projectId", type: "string", description: "Atlas project ID", required: true },
{ name: "clusterName", type: "string", description: "Atlas cluster name", required: true },
{ name: "region", type: "string", description: "Region of the cluster", required: false },
];

export const databaseCollectionInvalidArgs = [
{},
{ database: "test" },
Expand All @@ -252,6 +262,53 @@ export const databaseCollectionInvalidArgs = [
{ database: "test", collection: [] },
];

export const projectIdInvalidArgs = [
{},
{ projectId: 123 },
{ projectId: [] },
{ projectId: "!✅invalid" },
{ projectId: "invalid-test-project-id" },
];

export const clusterNameInvalidArgs = [
{ clusterName: 123 },
{ clusterName: [] },
{ clusterName: "!✅invalid" },
{ clusterName: "a".repeat(65) }, // too long
];

export const projectAndClusterInvalidArgs = [
{},
{ projectId: "507f1f77bcf86cd799439011" }, // missing clusterName
{ clusterName: "testCluster" }, // missing projectId
{ projectId: 123, clusterName: "testCluster" },
{ projectId: "507f1f77bcf86cd799439011", clusterName: 123 },
{ projectId: "invalid", clusterName: "testCluster" },
{ projectId: "507f1f77bcf86cd799439011", clusterName: "!✅invalid" },
];

export const organizationIdInvalidArgs = [
{ organizationId: 123 },
{ organizationId: [] },
{ organizationId: "!✅invalid" },
{ organizationId: "invalid-test-org-id" },
];

export const orgIdInvalidArgs = [
{ orgId: 123 },
{ orgId: [] },
{ orgId: "!✅invalid" },
{ orgId: "invalid-test-org-id" },
];

export const usernameInvalidArgs = [
{},
{ username: 123 },
{ username: [] },
{ username: "!✅invalid" },
{ username: "a".repeat(101) }, // too long
];

export const databaseInvalidArgs = [{}, { database: 123 }, { database: [] }];

export function validateToolMetadata(
Expand Down
27 changes: 26 additions & 1 deletion tests/integration/tools/atlas/accessLists.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describeWithAtlas, withProject } from "./atlasHelpers.js";
import { expectDefined, getResponseElements } from "../../helpers.js";
import {
expectDefined,
getResponseElements,
projectIdInvalidArgs,
validateThrowsForInvalidArguments,
validateToolMetadata,
} from "../../helpers.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureCurrentIpInAccessList } from "../../../../src/common/atlas/accessListUtils.js";

Expand All @@ -12,6 +18,25 @@ function generateRandomIp(): string {
}

describeWithAtlas("ip access lists", (integration) => {
describe("should have correct metadata and validate invalid arguments", () => {
validateToolMetadata(
integration,
"atlas-inspect-access-list",
"Inspect Ip/CIDR ranges with access to your MongoDB Atlas clusters.",
[
{
name: "projectId",
type: "string",
description: "Atlas project ID",
required: true,
},
]
);

validateThrowsForInvalidArguments(integration, "atlas-inspect-access-list", projectIdInvalidArgs);
validateThrowsForInvalidArguments(integration, "atlas-create-access-list", projectIdInvalidArgs);
});

withProject(integration, ({ getProjectId }) => {
const ips = [generateRandomIp(), generateRandomIp()];
const cidrBlocks = [generateRandomIp() + "/16", generateRandomIp() + "/24"];
Expand Down
27 changes: 18 additions & 9 deletions tests/integration/tools/atlas/alerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { expectDefined, getResponseElements } from "../../helpers.js";
import {
getResponseElements,
projectIdInvalidArgs,
validateThrowsForInvalidArguments,
validateToolMetadata,
} from "../../helpers.js";
import { parseTable, describeWithAtlas, withProject } from "./atlasHelpers.js";
import { expect, it } from "vitest";
import { expect, it, describe } from "vitest";

describeWithAtlas("atlas-list-alerts", (integration) => {
it("should have correct metadata", async () => {
const { tools } = await integration.mcpClient().listTools();
const listAlerts = tools.find((tool) => tool.name === "atlas-list-alerts");
expectDefined(listAlerts);
expect(listAlerts.inputSchema.type).toBe("object");
expectDefined(listAlerts.inputSchema.properties);
expect(listAlerts.inputSchema.properties).toHaveProperty("projectId");
describe("should have correct metadata and validate invalid arguments", () => {
validateToolMetadata(integration, "atlas-list-alerts", "List MongoDB Atlas alerts", [
{
name: "projectId",
type: "string",
description: "Atlas project ID to list alerts for",
required: true,
},
]);

validateThrowsForInvalidArguments(integration, "atlas-list-alerts", projectIdInvalidArgs);
});

withProject(integration, ({ getProjectId }) => {
Expand Down
27 changes: 19 additions & 8 deletions tests/integration/tools/atlas/atlasHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,12 @@ import { afterAll, beforeAll, describe } from "vitest";
export type IntegrationTestFunction = (integration: IntegrationTest) => void;

export function describeWithAtlas(name: string, fn: IntegrationTestFunction): void {
const describeFn =
!process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length
? describe.skip
: describe;
describeFn(name, () => {
describe(name, () => {
const integration = setupIntegrationTest(
() => ({
...defaultTestConfig,
apiClientId: process.env.MDB_MCP_API_CLIENT_ID,
apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET,
apiClientId: process.env.MDB_MCP_API_CLIENT_ID || "test-client",
apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET || "test-secret",
apiBaseUrl: process.env.MDB_MCP_API_BASE_URL ?? "https://cloud-dev.mongodb.com",
}),
() => defaultDriverOptions
Expand All @@ -34,8 +30,23 @@ interface ProjectTestArgs {

type ProjectTestFunction = (args: ProjectTestArgs) => void;

export function withCredentials(integration: IntegrationTest, fn: IntegrationTestFunction): SuiteCollector<object> {
const describeFn =
!process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length
? describe.skip
: describe;
return describeFn("with credentials", () => {
fn(integration);
});
}

export function withProject(integration: IntegrationTest, fn: ProjectTestFunction): SuiteCollector<object> {
return describe("with project", () => {
const describeFn =
!process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length
? describe.skip
: describe;

return describeFn("with project", () => {
let projectId: string = "";
let ipAddress: string = "";

Expand Down
24 changes: 23 additions & 1 deletion tests/integration/tools/atlas/clusters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { Session } from "../../../../src/common/session.js";
import { expectDefined, getDataFromUntrustedContent, getResponseElements, sleep } from "../../helpers.js";
import {
expectDefined,
getResponseElements,
getDataFromUntrustedContent,
createClusterParameters,
projectAndClusterInvalidArgs,
validateThrowsForInvalidArguments,
validateToolMetadata,
sleep,
} from "../../helpers.js";
import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js";
import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -63,6 +72,19 @@
}

describeWithAtlas("clusters", (integration) => {
describe("should have correct metadata and validate invalid arguments", () => {
validateToolMetadata(
integration,
"atlas-create-free-cluster",
"Create a free MongoDB Atlas cluster",
createClusterParameters
);

expect(() => {
validateThrowsForInvalidArguments(integration, "atlas-create-free-cluster", projectAndClusterInvalidArgs);
}).not.toThrow();
});

withProject(integration, ({ getProjectId, getIpAddress }) => {
const clusterName = "ClusterTest-" + randomId;

Expand All @@ -83,7 +105,7 @@
expect(createFreeCluster.inputSchema.type).toBe("object");
expectDefined(createFreeCluster.inputSchema.properties);
expect(createFreeCluster.inputSchema.properties).toHaveProperty("projectId");
expect(createFreeCluster.inputSchema.properties).toHaveProperty("name");

Check failure on line 108 in tests/integration/tools/atlas/clusters.test.ts

View workflow job for this annotation

GitHub Actions / Run Atlas tests

tests/integration/tools/atlas/clusters.test.ts > clusters > with project > atlas-create-free-cluster > should have correct metadata

AssertionError: expected { projectId: { …(5) }, …(2) } to have property "name" ❯ tests/integration/tools/atlas/clusters.test.ts:108:66
expect(createFreeCluster.inputSchema.properties).toHaveProperty("region");
});

Expand Down Expand Up @@ -132,7 +154,7 @@
arguments: { projectId, clusterName: clusterName },
});
const elements = getResponseElements(response.content);
expect(elements).toHaveLength(2);

Check failure on line 157 in tests/integration/tools/atlas/clusters.test.ts

View workflow job for this annotation

GitHub Actions / Run Atlas tests

tests/integration/tools/atlas/clusters.test.ts > clusters > with project > atlas-inspect-cluster > returns cluster data

AssertionError: expected [ { type: 'text', …(1) } ] to have a length of 2 but got 1 - Expected + Received - 2 + 1 ❯ tests/integration/tools/atlas/clusters.test.ts:157:34
expect(elements[0]?.text).toContain("Cluster details:");
expect(elements[1]?.text).toContain("<untrusted-user-data-");
expect(elements[1]?.text).toContain(`${clusterName} | `);
Expand All @@ -157,7 +179,7 @@
.callTool({ name: "atlas-list-clusters", arguments: { projectId } });

const elements = getResponseElements(response);
expect(elements).toHaveLength(2);

Check failure on line 182 in tests/integration/tools/atlas/clusters.test.ts

View workflow job for this annotation

GitHub Actions / Run Atlas tests

tests/integration/tools/atlas/clusters.test.ts > clusters > with project > atlas-list-clusters > returns clusters by project

AssertionError: expected [ { type: 'text', …(1) } ] to have a length of 2 but got 1 - Expected + Received - 2 + 1 ❯ tests/integration/tools/atlas/clusters.test.ts:182:34

expect(elements[1]?.text).toContain("<untrusted-user-data-");
expect(elements[1]?.text).toContain(`${clusterName} | `);
Expand Down
Loading
Loading