From 16115a7f5c3a04d51f59ada13e458946f015f20e Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 9 Apr 2025 18:07:40 +0100 Subject: [PATCH 1/3] feat: add atlas access list tools --- scripts/filter.ts | 2 + src/common/atlas/client.ts | 15 ++- src/common/atlas/openapi.d.ts | 142 +++++++++++++++++++++++++++ src/config.ts | 1 + src/tools/atlas/createAccessList.ts | 56 +++++++++++ src/tools/atlas/inspectAccessList.ts | 35 +++++++ src/tools/atlas/tools.ts | 4 + 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/tools/atlas/createAccessList.ts create mode 100644 src/tools/atlas/inspectAccessList.ts diff --git a/scripts/filter.ts b/scripts/filter.ts index 01d7c226..3886528a 100644 --- a/scripts/filter.ts +++ b/scripts/filter.ts @@ -24,6 +24,8 @@ function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document { "listClusters", "createCluster", "listClustersForAllProjects", + "listProjectIpAccessLists", + "createProjectIpAccessList", ]; const filteredPaths = {}; diff --git a/src/common/atlas/client.ts b/src/common/atlas/client.ts index 7629f258..8e054eb4 100644 --- a/src/common/atlas/client.ts +++ b/src/common/atlas/client.ts @@ -6,6 +6,8 @@ import { PaginatedAtlasGroupView, ClusterDescription20240805, PaginatedClusterDescription20240805, + PaginatedNetworkAccessView, + NetworkPermissionEntry, } from "./openapi.js"; export interface OAuthToken { @@ -65,7 +67,7 @@ export class ApiClient { credentials: !this.token?.access_token ? undefined : "include", headers: { "Content-Type": "application/json", - Accept: "application/vnd.atlas.2025-04-07+json", + Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`, "User-Agent": config.userAgent, ...authHeaders, }, @@ -269,6 +271,17 @@ export class ApiClient { return await this.do("/groups"); } + async listProjectIpAccessLists(groupId: string): Promise { + return await this.do(`/groups/${groupId}/accessList`); + } + + async createProjectIpAccessList(groupId: string, entries: NetworkPermissionEntry[]): Promise { + return await this.do(`/groups/${groupId}/accessList`, { + method: "POST", + body: JSON.stringify(entries), + }); + } + async getProject(groupId: string): Promise { return await this.do(`/groups/${groupId}`); } diff --git a/src/common/atlas/openapi.d.ts b/src/common/atlas/openapi.d.ts index e9458381..e10c8758 100644 --- a/src/common/atlas/openapi.d.ts +++ b/src/common/atlas/openapi.d.ts @@ -68,6 +68,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atlas/v2/groups/{groupId}/accessList": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return Project IP Access List + * @description Returns all access list entries from the specified project's IP access list. Each entry in the project's IP access list contains either one IP address or one CIDR-notated block of IP addresses. MongoDB Cloud only allows client connections to the cluster from entries in the project's IP access list. To use this resource, the requesting Service Account or API Key must have the Project Read Only or Project Charts Admin roles. This resource replaces the whitelist resource. MongoDB Cloud removed whitelists in July 2021. Update your applications to use this new resource. The `/groups/{GROUP-ID}/accessList` endpoint manages the database IP access list. This endpoint is distinct from the `orgs/{ORG-ID}/apiKeys/{API-KEY-ID}/accesslist` endpoint, which manages the access list for MongoDB Cloud organizations. + */ + get: operations["listProjectIpAccessLists"]; + put?: never; + /** + * Add Entries to Project IP Access List + * @description Adds one or more access list entries to the specified project. MongoDB Cloud only allows client connections to the cluster from entries in the project's IP access list. Write each entry as either one IP address or one CIDR-notated block of IP addresses. To use this resource, the requesting Service Account or API Key must have the Project Owner or Project Charts Admin roles. This resource replaces the whitelist resource. MongoDB Cloud removed whitelists in July 2021. Update your applications to use this new resource. The `/groups/{GROUP-ID}/accessList` endpoint manages the database IP access list. This endpoint is distinct from the `orgs/{ORG-ID}/apiKeys/{API-KEY-ID}/accesslist` endpoint, which manages the access list for MongoDB Cloud organizations. This endpoint doesn't support concurrent `POST` requests. You must submit multiple `POST` requests synchronously. + */ + post: operations["createProjectIpAccessList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atlas/v2/groups/{groupId}/clusters": { parameters: { query?: never; @@ -4711,6 +4735,28 @@ export interface components { */ type: "MONTHLY"; }; + NetworkPermissionEntry: { + /** @description Unique string of the Amazon Web Services (AWS) security group that you want to add to the project's IP access list. Your IP access list entry can be one **awsSecurityGroup**, one **cidrBlock**, or one **ipAddress**. You must configure Virtual Private Connection (VPC) peering for your project before you can add an AWS security group to an IP access list. You cannot set AWS security groups as temporary access list entries. Don't set this parameter if you set **cidrBlock** or **ipAddress**. */ + awsSecurityGroup?: string; + /** @description Range of IP addresses in Classless Inter-Domain Routing (CIDR) notation that you want to add to the project's IP access list. Your IP access list entry can be one **awsSecurityGroup**, one **cidrBlock**, or one **ipAddress**. Don't set this parameter if you set **awsSecurityGroup** or **ipAddress**. */ + cidrBlock?: string; + /** @description Remark that explains the purpose or scope of this IP access list entry. */ + comment?: string; + /** + * Format: date-time + * @description Date and time after which MongoDB Cloud deletes the temporary access list entry. This parameter expresses its value in the ISO 8601 timestamp format in UTC and can include the time zone designation. The date must be later than the current date but no later than one week after you submit this request. The resource returns this parameter if you specified an expiration date when creating this IP access list entry. + */ + deleteAfterDate?: string; + /** + * @description Unique 24-hexadecimal digit string that identifies the project that contains the IP access list to which you want to add one or more entries. + * @example 32b6e34b3d91647abb20e7b8 + */ + readonly groupId?: string; + /** @description IP address that you want to add to the project's IP access list. Your IP access list entry can be one **awsSecurityGroup**, one **cidrBlock**, or one **ipAddress**. Don't set this parameter if you set **awsSecurityGroup** or **cidrBlock**. */ + ipAddress?: string; + /** @description List of one or more Uniform Resource Locators (URLs) that point to API sub-resources, related API resources, or both. RFC 5988 outlines these relationships. */ + readonly links?: components["schemas"]["Link"][]; + }; /** * On-Demand Cloud Provider Snapshot Source * @description On-Demand Cloud Provider Snapshots as Source for a Data Lake Pipeline. @@ -4898,6 +4944,17 @@ export interface components { */ readonly totalCount?: number; }; + PaginatedNetworkAccessView: { + /** @description List of one or more Uniform Resource Locators (URLs) that point to API sub-resources, related API resources, or both. RFC 5988 outlines these relationships. */ + readonly links?: components["schemas"]["Link"][]; + /** @description List of returned documents that MongoDB Cloud provides when completing this request. */ + readonly results?: components["schemas"]["NetworkPermissionEntry"][]; + /** + * Format: int32 + * @description Total number of documents available. MongoDB Cloud omits this value if `includeCount` is set to `false`. The total number is an estimate and may not be exact. + */ + readonly totalCount?: number; + }; PaginatedOrgGroupView: { /** @description List of one or more Uniform Resource Locators (URLs) that point to API sub-resources, related API resources, or both. RFC 5988 outlines these relationships. */ readonly links?: components["schemas"]["Link"][]; @@ -6851,6 +6908,7 @@ export type IngestionSource = components["schemas"]["IngestionSource"]; export type InvoiceLineItem = components["schemas"]["InvoiceLineItem"]; export type Link = components["schemas"]["Link"]; export type MonthlyScheduleView = components["schemas"]["MonthlyScheduleView"]; +export type NetworkPermissionEntry = components["schemas"]["NetworkPermissionEntry"]; export type OnDemandCpsSnapshotSource = components["schemas"]["OnDemandCpsSnapshotSource"]; export type OnlineArchiveSchedule = components["schemas"]["OnlineArchiveSchedule"]; export type OrgActiveUserResponse = components["schemas"]["OrgActiveUserResponse"]; @@ -6860,6 +6918,7 @@ export type OrgUserResponse = components["schemas"]["OrgUserResponse"]; export type OrgUserRolesResponse = components["schemas"]["OrgUserRolesResponse"]; export type PaginatedAtlasGroupView = components["schemas"]["PaginatedAtlasGroupView"]; export type PaginatedClusterDescription20240805 = components["schemas"]["PaginatedClusterDescription20240805"]; +export type PaginatedNetworkAccessView = components["schemas"]["PaginatedNetworkAccessView"]; export type PaginatedOrgGroupView = components["schemas"]["PaginatedOrgGroupView"]; export type PeriodicCpsSnapshotSource = components["schemas"]["PeriodicCpsSnapshotSource"]; export type ReplicationSpec20240805 = components["schemas"]["ReplicationSpec20240805"]; @@ -7094,6 +7153,89 @@ export interface operations { 500: components["responses"]["internalServerError"]; }; }; + listProjectIpAccessLists: { + parameters: { + query?: { + /** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */ + envelope?: components["parameters"]["envelope"]; + /** @description Flag that indicates whether the response returns the total number of items (**totalCount**) in the response. */ + includeCount?: components["parameters"]["includeCount"]; + /** @description Number of items that the response returns per page. */ + itemsPerPage?: components["parameters"]["itemsPerPage"]; + /** @description Number of the page that displays the current set of the total objects that the response returns. */ + pageNum?: components["parameters"]["pageNum"]; + /** @description Flag that indicates whether the response body should be in the prettyprint format. */ + pretty?: components["parameters"]["pretty"]; + }; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2023-01-01+json": components["schemas"]["PaginatedNetworkAccessView"]; + }; + }; + 401: components["responses"]["unauthorized"]; + 500: components["responses"]["internalServerError"]; + }; + }; + createProjectIpAccessList: { + parameters: { + query?: { + /** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */ + envelope?: components["parameters"]["envelope"]; + /** @description Flag that indicates whether the response returns the total number of items (**totalCount**) in the response. */ + includeCount?: components["parameters"]["includeCount"]; + /** @description Number of items that the response returns per page. */ + itemsPerPage?: components["parameters"]["itemsPerPage"]; + /** @description Number of the page that displays the current set of the total objects that the response returns. */ + pageNum?: components["parameters"]["pageNum"]; + /** @description Flag that indicates whether the response body should be in the prettyprint format. */ + pretty?: components["parameters"]["pretty"]; + }; + header?: never; + path: { + /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + * + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + groupId: components["parameters"]["groupId"]; + }; + cookie?: never; + }; + /** @description One or more access list entries to add to the specified project. */ + requestBody: { + content: { + "application/vnd.atlas.2023-01-01+json": components["schemas"]["NetworkPermissionEntry"][]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2023-01-01+json": components["schemas"]["PaginatedNetworkAccessView"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 500: components["responses"]["internalServerError"]; + }; + }; listClusters: { parameters: { query?: { diff --git a/src/config.ts b/src/config.ts index 75e22803..6c8feaa6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ const packageMetadata = fs.readFileSync(path.join(__dirname, "..", "package.json const packageJson = JSON.parse(packageMetadata); export const config = { + atlasApiVersion: `2025-03-12`, version: packageJson.version, apiBaseURL: process.env.API_BASE_URL || "https://cloud.mongodb.com/", clientID: process.env.CLIENT_ID || "0oabtxactgS3gHIR0297", diff --git a/src/tools/atlas/createAccessList.ts b/src/tools/atlas/createAccessList.ts new file mode 100644 index 00000000..1fcc4c2e --- /dev/null +++ b/src/tools/atlas/createAccessList.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { AtlasToolBase } from "./atlasTool.js"; +import { ToolArgs } from "../tool.js"; +import { NetworkPermissionEntry } from "../../common/atlas/openapi.js"; + +const DEFAULT_COMMENT = "Added by Atlas MCP"; + +export class CreateAccessListTool extends AtlasToolBase { + protected name = "atlas-create-access-list"; + protected description = "Allow Ip/CIDR ranges to access your MongoDB Atlas clusters."; + protected argsShape = { + projectId: z.string().describe("Atlas project ID"), + ipAddresses: z.array(z.string().ip({ version: "v4" })).describe("IP addresses to allow access from").optional(), + cidrBlocks: z.array(z.string().cidr()).describe("CIDR blocks to allow access from").optional(), + comment: z.string().describe("Comment for the access list entries").default(DEFAULT_COMMENT).optional(), + }; + + protected async execute({projectId, ipAddresses, cidrBlocks, comment}: ToolArgs): Promise { + await this.ensureAuthenticated(); + + if (!ipAddresses?.length && !cidrBlocks?.length) { + throw new Error("Either ipAddresses or cidrBlocks must be provided."); + } + + console.error(`ipAddresses:`, JSON.stringify(ipAddresses, null, 2)); + console.error(`cidrBlocks:`, JSON.stringify(cidrBlocks, null, 2)); + + + + const ipInputs = (ipAddresses || []).map((ipAddress) => ({ + groupId: projectId, + ipAddress, + comment: comment || DEFAULT_COMMENT, + })); + + const cidrInputs = (cidrBlocks || []).map((cidrBlock) => ({ + groupId: projectId, + cidrBlock, + comment: comment || DEFAULT_COMMENT, + })); + + const inputs = [...ipInputs, ...cidrInputs]; + + await this.apiClient.createProjectIpAccessList(projectId, inputs); + + return { + content: [ + { + type: "text", + text: `IP/CIDR ranges added to access list for project ${projectId}.`, + }, + ], + }; + } +} diff --git a/src/tools/atlas/inspectAccessList.ts b/src/tools/atlas/inspectAccessList.ts new file mode 100644 index 00000000..7b72e224 --- /dev/null +++ b/src/tools/atlas/inspectAccessList.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { AtlasToolBase } from "./atlasTool.js"; +import { ToolArgs } from "../tool.js"; + +export class InspectAccessListTool extends AtlasToolBase { + protected name = "atlas-inspect-access-list"; + protected description = "Inspect Ip/CIDR ranges with access to your MongoDB Atlas clusters."; + protected argsShape = { + projectId: z.string().describe("Atlas project ID"), + }; + + protected async execute({projectId}: ToolArgs): Promise { + await this.ensureAuthenticated(); + + const accessList = await this.apiClient.listProjectIpAccessLists(projectId); + + if (!accessList.results?.length) { + throw new Error("No access list entries found."); + } + + return { + content: [ + { + type: "text", + text: `IP ADDRESS | CIDR | COMMENT +------|------|------ +` + (accessList.results || []).map((entry) => { + return `${entry.ipAddress} | ${entry.cidrBlock} | ${entry.comment}`; + }).join("\n") + } + ] + }; + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index 1b4894f8..5a421d01 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -7,6 +7,8 @@ import { ListClustersTool } from "./listClusters.js"; import { ListProjectsTool } from "./listProjects.js"; import { InspectClusterTool } from "./inspectCluster.js"; import { CreateFreeClusterTool } from "./createFreeCluster.js"; +import { CreateAccessListTool } from "./createAccessList.js"; +import { InspectAccessListTool } from "./inspectAccessList.js"; export function registerAtlasTools(server: McpServer, state: State, apiClient: ApiClient) { const tools: ToolBase[] = [ @@ -15,6 +17,8 @@ export function registerAtlasTools(server: McpServer, state: State, apiClient: A new ListProjectsTool(state, apiClient), new InspectClusterTool(state, apiClient), new CreateFreeClusterTool(state, apiClient), + new CreateAccessListTool(state, apiClient), + new InspectAccessListTool(state, apiClient), ]; for (const tool of tools) { From 5d454c6602c5f0c165ac3dd4c274973c13bb9b4e Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 9 Apr 2025 18:10:35 +0100 Subject: [PATCH 2/3] fix: styles --- src/common/atlas/client.ts | 5 ++++- src/tools/atlas/createAccessList.ts | 19 +++++++++++-------- src/tools/atlas/inspectAccessList.ts | 18 +++++++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/common/atlas/client.ts b/src/common/atlas/client.ts index 8e054eb4..f8bde6dc 100644 --- a/src/common/atlas/client.ts +++ b/src/common/atlas/client.ts @@ -275,7 +275,10 @@ export class ApiClient { return await this.do(`/groups/${groupId}/accessList`); } - async createProjectIpAccessList(groupId: string, entries: NetworkPermissionEntry[]): Promise { + async createProjectIpAccessList( + groupId: string, + entries: NetworkPermissionEntry[] + ): Promise { return await this.do(`/groups/${groupId}/accessList`, { method: "POST", body: JSON.stringify(entries), diff --git a/src/tools/atlas/createAccessList.ts b/src/tools/atlas/createAccessList.ts index 1fcc4c2e..9f198720 100644 --- a/src/tools/atlas/createAccessList.ts +++ b/src/tools/atlas/createAccessList.ts @@ -11,29 +11,32 @@ export class CreateAccessListTool extends AtlasToolBase { protected description = "Allow Ip/CIDR ranges to access your MongoDB Atlas clusters."; protected argsShape = { projectId: z.string().describe("Atlas project ID"), - ipAddresses: z.array(z.string().ip({ version: "v4" })).describe("IP addresses to allow access from").optional(), + ipAddresses: z + .array(z.string().ip({ version: "v4" })) + .describe("IP addresses to allow access from") + .optional(), cidrBlocks: z.array(z.string().cidr()).describe("CIDR blocks to allow access from").optional(), comment: z.string().describe("Comment for the access list entries").default(DEFAULT_COMMENT).optional(), }; - protected async execute({projectId, ipAddresses, cidrBlocks, comment}: ToolArgs): Promise { + protected async execute({ + projectId, + ipAddresses, + cidrBlocks, + comment, + }: ToolArgs): Promise { await this.ensureAuthenticated(); if (!ipAddresses?.length && !cidrBlocks?.length) { throw new Error("Either ipAddresses or cidrBlocks must be provided."); } - console.error(`ipAddresses:`, JSON.stringify(ipAddresses, null, 2)); - console.error(`cidrBlocks:`, JSON.stringify(cidrBlocks, null, 2)); - - - const ipInputs = (ipAddresses || []).map((ipAddress) => ({ groupId: projectId, ipAddress, comment: comment || DEFAULT_COMMENT, })); - + const cidrInputs = (cidrBlocks || []).map((cidrBlock) => ({ groupId: projectId, cidrBlock, diff --git a/src/tools/atlas/inspectAccessList.ts b/src/tools/atlas/inspectAccessList.ts index 7b72e224..79fea3b0 100644 --- a/src/tools/atlas/inspectAccessList.ts +++ b/src/tools/atlas/inspectAccessList.ts @@ -10,7 +10,7 @@ export class InspectAccessListTool extends AtlasToolBase { projectId: z.string().describe("Atlas project ID"), }; - protected async execute({projectId}: ToolArgs): Promise { + protected async execute({ projectId }: ToolArgs): Promise { await this.ensureAuthenticated(); const accessList = await this.apiClient.listProjectIpAccessLists(projectId); @@ -23,13 +23,17 @@ export class InspectAccessListTool extends AtlasToolBase { content: [ { type: "text", - text: `IP ADDRESS | CIDR | COMMENT + text: + `IP ADDRESS | CIDR | COMMENT ------|------|------ -` + (accessList.results || []).map((entry) => { - return `${entry.ipAddress} | ${entry.cidrBlock} | ${entry.comment}`; - }).join("\n") - } - ] +` + + (accessList.results || []) + .map((entry) => { + return `${entry.ipAddress} | ${entry.cidrBlock} | ${entry.comment}`; + }) + .join("\n"), + }, + ], }; } } From 304f4acd9f3458fb8d08172ec3cce62eaaba5bc7 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 9 Apr 2025 18:12:19 +0100 Subject: [PATCH 3/3] fix: styles --- src/tools/atlas/createAccessList.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/atlas/createAccessList.ts b/src/tools/atlas/createAccessList.ts index 9f198720..0a6afeae 100644 --- a/src/tools/atlas/createAccessList.ts +++ b/src/tools/atlas/createAccessList.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AtlasToolBase } from "./atlasTool.js"; import { ToolArgs } from "../tool.js"; -import { NetworkPermissionEntry } from "../../common/atlas/openapi.js"; const DEFAULT_COMMENT = "Added by Atlas MCP";