From a2d44adb8787649263e8f3c41fa9531431e53c99 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 11 Apr 2025 11:04:16 +0100 Subject: [PATCH 1/3] feat: change Atlas API auth to service accounts --- README.md | 87 +++++++++- package-lock.json | 106 ++++++++++++ package.json | 1 + src/common/atlas/apiClient.ts | 235 ++++----------------------- src/common/atlas/auth.ts | 32 ---- src/config.ts | 4 +- src/server.ts | 24 ++- src/state.ts | 12 +- src/tools/atlas/atlasTool.ts | 9 +- src/tools/atlas/auth.ts | 55 ------- src/tools/atlas/createAccessList.ts | 4 +- src/tools/atlas/createDBUser.ts | 2 +- src/tools/atlas/createFreeCluster.ts | 2 +- src/tools/atlas/inspectAccessList.ts | 2 +- src/tools/atlas/inspectCluster.ts | 2 +- src/tools/atlas/listClusters.ts | 6 +- src/tools/atlas/listDBUsers.ts | 2 +- src/tools/atlas/tools.ts | 4 +- 18 files changed, 255 insertions(+), 334 deletions(-) delete mode 100644 src/common/atlas/auth.ts delete mode 100644 src/tools/atlas/auth.ts diff --git a/README.md b/README.md index 87a70531..a7f536b3 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,14 @@ npm run build #### MongoDB Atlas Tools -- `atlas-auth` - Authenticate to MongoDB Atlas - `atlas-list-clusters` - Lists MongoDB Atlas clusters - `atlas-list-projects` - Lists MongoDB Atlas projects - `atlas-inspect-cluster` - Inspect a specific MongoDB Atlas cluster - `atlas-create-free-cluster` - Create a free MongoDB Atlas cluster - `atlas-create-access-list` - Configure IP/CIDR access list for MongoDB Atlas clusters - `atlas-inspect-access-list` - Inspect IP/CIDR ranges with access to MongoDB Atlas clusters +- `atlas-list-db-users` - List MongoDB Atlas database users +- `atlas-create-db-user` - List MongoDB Atlas database users #### MongoDB Database Tools @@ -110,6 +111,8 @@ It should look like this } ``` +Notes: You can configure the server with atlas access, make sure to follow configuration section for more details. + Step 3: Open the copilot chat and check that the toolbox icon is visible and has the mcp server listed. Step 4: Try running a command @@ -146,10 +149,88 @@ Paste the mcp server configuration into the file Step 3: Launch Claude Desktop and click on the hammer icon, the Demo MCP server should be detected. Type in the chat "show me a demo of MCP" and allow the tool to get access. -- Detailed instructions with screenshots can be found in this [document](https://docs.google.com/document/d/1_C8QBMZ5rwImV_9v4G96661OqcBk1n1SfEgKyNalv9c/edit?tab=t.2hhewstzj7ck#bookmark=id.nktw0lg0fn7t). - Note: If you make changes to your MCP server code, rebuild the project with `npm run build` and restart the server and Claude Desktop. +## Configuration + +The MongoDB MCP Server can be configured using multiple methods, with the following precedence (highest to lowest): + +1. Command-line arguments +2. Environment variables +3. Configuration file +4. Default values + +### Configuration Options + +| Option | Description | +| ------------------ | --------------------------------------------------------------------------- | +| `apiClientId` | Atlas API client ID for authentication | +| `apiClientSecret` | Atlas API client secret for authentication | +| `stateFile` | Path to store application state (default ~/.mongodb/mongodb-mcp/state.json) | +| `connectionString` | MongoDB connection string for direct database connections | + +### Atlas API Access + +To use the Atlas API tools, you'll need to create a service account in MongoDB Atlas: + +1. **Create a Service Account:** + - Log in to MongoDB Atlas at [cloud.mongodb.com](https://cloud.mongodb.com) + - Navigate to Access Manager > Organization Access + - Click Add New > Applications > Service Accounts + - Enter name, description and expiration for your service account (e.g., "MCP, MCP Server Access, 7 days") + - Select appropriate permissions (for full access, use Organization Owner) + - Click "Create" + +2. **Save Client Credentials:** + - After creation, you'll be shown the Client ID and Client Secret + - **Important:** Copy and save the Client Secret immediately as it won't be displayed again + +3. **Add Access List Entry (Optional but recommended):** + - Add your IP address to the API access list + +4. **Configure the MCP Server:** + - Use one of the configuration methods below to set your `apiClientId` and `apiClientSecret` + +### Configuration Methods + +#### Configuration File + +Create a JSON configuration file at one of these locations: + +- Linux/macOS: `/etc/mongodb-mcp.conf` +- Windows: `%LOCALAPPDATA%\mongodb\mongodb-mcp\mongodb-mcp.conf` + +Example configuration file: + +```json +{ + "apiClientId": "your-atlas-client-id", + "apiClientSecret": "your-atlas-client-secret", + "connectionString": "mongodb+srv://username:password@cluster.mongodb.net/myDatabase" +} +``` + +#### Environment Variables + +Set environment variables with the prefix `MDB_MCP_` followed by the option name in uppercase with underscores: + +```shell +# Set Atlas API credentials +export MDB_MCP_API_CLIENT_ID="your-atlas-client-id" +export MDB_MCP_API_CLIENT_SECRET="your-atlas-client-secret" + +# Set a custom MongoDB connection string +export MDB_MCP_CONNECTION_STRING="mongodb+srv://username:password@cluster.mongodb.net/myDatabase" +``` + +#### Command-Line Arguments + +Pass configuration options as command-line arguments when starting the server: + +```shell +node dist/index.js --apiClientId="your-atlas-client-id" --apiClientSecret="your-atlas-client-secret" --connectionString="mongodb+srv://username:password@cluster.mongodb.net/myDatabase" +``` + ## 🤝 Contributing Interested in contributing? Great! Please check our [Contributing Guide](CONTRIBUTING.md) for guidelines on code contributions, standards, adding new tools, and troubleshooting information. diff --git a/package-lock.json b/package-lock.json index 17a06913..536fdd2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "mongodb-redact": "^1.1.6", "mongodb-schema": "^12.6.2", "openapi-fetch": "^0.13.5", + "simple-oauth2": "^5.1.0", "yargs-parser": "^21.1.1", "zod": "^3.24.2" }, @@ -1971,6 +1972,53 @@ "dev": true, "license": "MIT" }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/topo/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5249,6 +5297,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -10257,6 +10332,25 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -13121,6 +13215,18 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-oauth2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz", + "integrity": "sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw==", + "license": "Apache-2.0", + "dependencies": { + "@hapi/hoek": "^11.0.4", + "@hapi/wreck": "^18.0.0", + "debug": "^4.3.4", + "joi": "^17.6.4" + } + }, "node_modules/simple-websocket": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/simple-websocket/-/simple-websocket-9.1.0.tgz", diff --git a/package.json b/package.json index 40c350b5..dae56b98 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "mongodb-redact": "^1.1.6", "mongodb-schema": "^12.6.2", "openapi-fetch": "^0.13.5", + "simple-oauth2": "^5.1.0", "yargs-parser": "^21.1.1", "zod": "^3.24.2" }, diff --git a/src/common/atlas/apiClient.ts b/src/common/atlas/apiClient.ts index 0c6615d7..7384bec9 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -1,28 +1,9 @@ import config from "../../config.js"; import createClient, { FetchOptions, Middleware } from "openapi-fetch"; +import { AccessToken, ClientCredentials } from "simple-oauth2"; import { paths, operations } from "./openapi.js"; -export interface OAuthToken { - access_token: string; - refresh_token: string; - scope: string; - id_token: string; - token_type: string; - expires_in: number; - expiry: Date; -} - -export interface OauthDeviceCode { - user_code: string; - verification_uri: string; - device_code: string; - expires_in: string; - interval: string; -} - -export type saveTokenFunction = (token: OAuthToken) => void | Promise; - export class ApiClientError extends Error { response?: Response; @@ -44,13 +25,14 @@ export class ApiClientError extends Error { } export interface ApiClientOptions { - token?: OAuthToken; - saveToken?: saveTokenFunction; + credentials: { + clientId: string; + clientSecret: string; + }; + baseUrl?: string; } export class ApiClient { - private token?: OAuthToken; - private saveToken?: saveTokenFunction; private client = createClient({ baseUrl: config.apiBaseUrl, headers: { @@ -58,14 +40,37 @@ export class ApiClient { Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`, }, }); + private oauth2Client = new ClientCredentials({ + client: { + id: this.options.credentials.clientId, + secret: this.options.credentials.clientSecret, + }, + auth: { + tokenHost: this.options.baseUrl || config.apiBaseUrl, + tokenPath: "/api/oauth/token", + }, + }); + private accessToken?: AccessToken; + + private getAccessToken = async () => { + if (!this.accessToken || this.accessToken.expired()) { + this.accessToken = await this.oauth2Client.getToken({}); + } + return this.accessToken.token.access_token; + }; + private authMiddleware = (apiClient: ApiClient): Middleware => ({ async onRequest({ request, schemaPath }) { if (schemaPath.startsWith("/api/private/unauth") || schemaPath.startsWith("/api/oauth")) { return undefined; } - if (await apiClient.validateToken()) { - request.headers.set("Authorization", `Bearer ${apiClient.token!.access_token}`); + + try { + const accessToken = await apiClient.getAccessToken(); + request.headers.set("Authorization", `Bearer ${accessToken}`); return request; + } catch { + // ignore not availble tokens, API will return 401 } }, }); @@ -77,185 +82,13 @@ export class ApiClient { }, }); - constructor(options: ApiClientOptions) { - const { token, saveToken } = options; - this.token = token; - this.saveToken = saveToken; + constructor(private options: ApiClientOptions) { this.client.use(this.authMiddleware(this)); this.client.use(this.errorMiddleware()); } - async storeToken(token: OAuthToken): Promise { - this.token = token; - - if (this.saveToken) { - await this.saveToken(token); - } - - return token; - } - - async authenticate(): Promise { - const endpoint = "api/private/unauth/account/device/authorize"; - - const authUrl = new URL(endpoint, config.apiBaseUrl); - - const response = await fetch(authUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: "openid profile offline_access", - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }).toString(), - }); - - if (!response.ok) { - throw await ApiClientError.fromResponse(response, `failed to initiate authentication`); - } - - return (await response.json()) as OauthDeviceCode; - } - - async retrieveToken(device_code: string): Promise { - const endpoint = "api/private/unauth/account/device/token"; - const url = new URL(endpoint, config.apiBaseUrl); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: config.clientId, - device_code: device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }).toString(), - }); - - if (response.ok) { - const tokenData = await response.json(); - const buf = Buffer.from(tokenData.access_token.split(".")[1], "base64").toString(); - const jwt = JSON.parse(buf); - const expiry = new Date(jwt.exp * 1000); - return await this.storeToken({ ...tokenData, expiry }); - } - try { - const errorResponse = await response.json(); - if (errorResponse.errorCode === "DEVICE_AUTHORIZATION_PENDING") { - throw await ApiClientError.fromResponse(response, "Authentication pending. Try again later."); - } else { - throw await ApiClientError.fromResponse( - response, - "Device code expired. Please restart the authentication process." - ); - } - } catch { - throw await ApiClientError.fromResponse( - response, - "Failed to retrieve token. Please check your device code." - ); - } - } - - async refreshToken(token?: OAuthToken): Promise { - const endpoint = "api/private/unauth/account/device/token"; - const url = new URL(endpoint, config.apiBaseUrl); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - refresh_token: (token || this.token)?.refresh_token || "", - grant_type: "refresh_token", - scope: "openid profile offline_access", - }).toString(), - }); - - if (!response.ok) { - throw await ApiClientError.fromResponse(response, "Failed to refresh token"); - } - const data = await response.json(); - - const buf = Buffer.from(data.access_token.split(".")[1], "base64").toString(); - const jwt = JSON.parse(buf); - const expiry = new Date(jwt.exp * 1000); - - const tokenToStore = { - ...data, - expiry, - }; - - return await this.storeToken(tokenToStore); - } - - async revokeToken(token?: OAuthToken): Promise { - const endpoint = "api/private/unauth/account/device/token"; - const url = new URL(endpoint, config.apiBaseUrl); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": config.userAgent, - }, - body: new URLSearchParams({ - client_id: config.clientId, - token: (token || this.token)?.access_token || "", - token_type_hint: "refresh_token", - }).toString(), - }); - - if (!response.ok) { - throw await ApiClientError.fromResponse(response); - } - - if (!token && this.token) { - this.token = undefined; - } - - return; - } - - private checkTokenExpiry(token?: OAuthToken): boolean { - try { - token = token || this.token; - if (!token || !token.access_token) { - return false; - } - if (!token.expiry) { - return false; - } - const expiryDelta = 10 * 1000; // 10 seconds in milliseconds - const expiryWithDelta = new Date(token.expiry.getTime() - expiryDelta); - return expiryWithDelta.getTime() > Date.now(); - } catch { - return false; - } - } - - async validateToken(token?: OAuthToken): Promise { - if (this.checkTokenExpiry(token)) { - return true; - } - - try { - await this.refreshToken(token); - return true; - } catch { - return false; - } - } - async getIpInfo() { - if (!(await this.validateToken())) { - throw new Error("Not Authenticated"); - } + const accessToken = await this.getAccessToken(); const endpoint = "api/private/ipinfo"; const url = new URL(endpoint, config.apiBaseUrl); @@ -263,7 +96,7 @@ export class ApiClient { method: "GET", headers: { Accept: "application/json", - Authorization: `Bearer ${this.token!.access_token}`, + Authorization: `Bearer ${accessToken}`, "User-Agent": config.userAgent, }, }); diff --git a/src/common/atlas/auth.ts b/src/common/atlas/auth.ts deleted file mode 100644 index ac0c876d..00000000 --- a/src/common/atlas/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ApiClient } from "./apiClient.js"; -import { State } from "../../state.js"; - -export async function ensureAuthenticated(state: State, apiClient: ApiClient): Promise { - if (!(await isAuthenticated(state, apiClient))) { - throw new Error("Not authenticated"); - } -} - -export async function isAuthenticated(state: State, apiClient: ApiClient): Promise { - switch (state.credentials.auth.status) { - case "not_auth": - return false; - case "requested": - try { - if (!state.credentials.auth.code) { - return false; - } - await apiClient.retrieveToken(state.credentials.auth.code.device_code); - return !!state.credentials.auth.token; - } catch { - return false; - } - case "issued": - if (!state.credentials.auth.token) { - return false; - } - return await apiClient.validateToken(); - default: - throw new Error("Unknown authentication status"); - } -} diff --git a/src/config.ts b/src/config.ts index 63bba69d..ccbf3e03 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,14 +10,14 @@ const { localDataPath, configPath } = getLocalDataPath(); // env variables. interface UserConfig extends Record { apiBaseUrl: string; - clientId: string; + apiClientId?: string; + apiClientSecret?: string; stateFile: string; connectionString?: string; } const defaults: UserConfig = { apiBaseUrl: "https://cloud.mongodb.com/", - clientId: "0oabtxactgS3gHIR0297", stateFile: path.join(localDataPath, "state.json"), }; diff --git a/src/server.ts b/src/server.ts index 0415f038..1df18e66 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { mongoLogId } from "mongodb-log-writer"; export class Server { state: State = defaultState; - apiClient: ApiClient | undefined = undefined; + apiClient?: ApiClient; initialized: boolean = false; private async init() { @@ -20,18 +20,14 @@ export class Server { await this.state.loadCredentials(); - this.apiClient = new ApiClient({ - token: this.state.credentials.auth.token, - saveToken: async (token) => { - if (!this.state) { - throw new Error("State is not initialized"); - } - this.state.credentials.auth.code = undefined; - this.state.credentials.auth.token = token; - this.state.credentials.auth.status = "issued"; - await this.state.persistCredentials(); - }, - }); + if (config.apiClientId && config.apiClientSecret) { + this.apiClient = new ApiClient({ + credentials: { + clientId: config.apiClientId!, + clientSecret: config.apiClientSecret, + }, + }); + } this.initialized = true; } @@ -44,7 +40,7 @@ export class Server { server.server.registerCapabilities({ logging: {} }); - registerAtlasTools(server, this.state, this.apiClient!); + registerAtlasTools(server, this.state, this.apiClient); registerMongoDBTools(server, this.state); return server; diff --git a/src/state.ts b/src/state.ts index 9cc79626..f99f3cb6 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,25 +1,15 @@ -import { OauthDeviceCode, OAuthToken } from "./common/atlas/apiClient.js"; import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { AsyncEntry } from "@napi-rs/keyring"; import logger from "./logger.js"; import { mongoLogId } from "mongodb-log-writer"; interface Credentials { - auth: { - status: "not_auth" | "requested" | "issued"; - code?: OauthDeviceCode; - token?: OAuthToken; - }; connectionString?: string; } export class State { private entry = new AsyncEntry("mongodb-mcp", "credentials"); - credentials: Credentials = { - auth: { - status: "not_auth", - }, - }; + credentials: Credentials = {}; serviceProvider?: NodeDriverServiceProvider; public async persistCredentials(): Promise { diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index 5622cff8..af759b82 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -1,17 +1,20 @@ import { ToolBase } from "../tool.js"; import { ApiClient } from "../../common/atlas/apiClient.js"; import { State } from "../../state.js"; -import { ensureAuthenticated } from "../../common/atlas/auth.js"; export abstract class AtlasToolBase extends ToolBase { constructor( state: State, - protected apiClient: ApiClient + protected apiClient?: ApiClient ) { super(state); } protected async ensureAuthenticated(): Promise { - await ensureAuthenticated(this.state, this.apiClient); + if (!this.apiClient) { + throw new Error( + "Not authenticated make sure to configure MCP server with MDB_MCP_API_CLIENT_ID and MDB_MCP_API_CLIENT_SECRET environment variables." + ); + } } } diff --git a/src/tools/atlas/auth.ts b/src/tools/atlas/auth.ts deleted file mode 100644 index aa33bd78..00000000 --- a/src/tools/atlas/auth.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { AtlasToolBase } from "./atlasTool.js"; -import { isAuthenticated } from "../../common/atlas/auth.js"; -import logger from "../../logger.js"; -import { mongoLogId } from "mongodb-log-writer"; - -export class AuthTool extends AtlasToolBase { - protected name = "atlas-auth"; - protected description = "Authenticate to MongoDB Atlas"; - protected argsShape = {}; - - private async isAuthenticated(): Promise { - return isAuthenticated(this.state, this.apiClient); - } - - async execute(): Promise { - if (await this.isAuthenticated()) { - logger.debug(mongoLogId(1_000_001), "auth", "Already authenticated!"); - return { - content: [{ type: "text", text: "You are already authenticated!" }], - }; - } - - try { - const code = await this.apiClient.authenticate(); - - this.state.credentials.auth.status = "requested"; - this.state.credentials.auth.code = code; - this.state.credentials.auth.token = undefined; - - await this.state.persistCredentials(); - - return { - content: [ - { - type: "text", - text: `Please authenticate by visiting ${code.verification_uri} and entering the code ${code.user_code}`, - }, - ], - }; - } catch (error: unknown) { - if (error instanceof Error) { - logger.error(mongoLogId(1_000_002), "auth", `Authentication error: ${error}`); - return { - content: [{ type: "text", text: `Authentication failed: ${error.message}` }], - }; - } - - logger.error(mongoLogId(1_000_003), "auth", `Unknown authentication error: ${error}`); - return { - content: [{ type: "text", text: "Authentication failed due to an unknown error." }], - }; - } - } -} diff --git a/src/tools/atlas/createAccessList.ts b/src/tools/atlas/createAccessList.ts index 7bcc4979..d136404f 100644 --- a/src/tools/atlas/createAccessList.ts +++ b/src/tools/atlas/createAccessList.ts @@ -39,7 +39,7 @@ export class CreateAccessListTool extends AtlasToolBase { })); if (currentIpAddress) { - const currentIp = await this.apiClient.getIpInfo(); + const currentIp = await this.apiClient!.getIpInfo(); const input = { groupId: projectId, ipAddress: currentIp.currentIpv4Address, @@ -56,7 +56,7 @@ export class CreateAccessListTool extends AtlasToolBase { const inputs = [...ipInputs, ...cidrInputs]; - await this.apiClient.createProjectIpAccessList({ + await this.apiClient!.createProjectIpAccessList({ params: { path: { groupId: projectId, diff --git a/src/tools/atlas/createDBUser.ts b/src/tools/atlas/createDBUser.ts index d2a3b5d6..b9112291 100644 --- a/src/tools/atlas/createDBUser.ts +++ b/src/tools/atlas/createDBUser.ts @@ -6,7 +6,7 @@ import { CloudDatabaseUser, DatabaseUserRole } from "../../common/atlas/openapi. export class CreateDBUserTool extends AtlasToolBase { protected name = "atlas-create-db-user"; - protected description = "Create an MongoDB Atlas user"; + protected description = "Create an MongoDB Atlas database user"; protected argsShape = { projectId: z.string().describe("Atlas project ID"), username: z.string().describe("Username for the new user"), diff --git a/src/tools/atlas/createFreeCluster.ts b/src/tools/atlas/createFreeCluster.ts index 6a903f48..4d9e7a89 100644 --- a/src/tools/atlas/createFreeCluster.ts +++ b/src/tools/atlas/createFreeCluster.ts @@ -38,7 +38,7 @@ export class CreateFreeClusterTool extends AtlasToolBase { terminationProtectionEnabled: false, } as unknown as ClusterDescription20240805; - await this.apiClient.createCluster({ + await this.apiClient!.createCluster({ params: { path: { groupId: projectId, diff --git a/src/tools/atlas/inspectAccessList.ts b/src/tools/atlas/inspectAccessList.ts index 6e0c2f07..4a6c8522 100644 --- a/src/tools/atlas/inspectAccessList.ts +++ b/src/tools/atlas/inspectAccessList.ts @@ -13,7 +13,7 @@ export class InspectAccessListTool extends AtlasToolBase { protected async execute({ projectId }: ToolArgs): Promise { await this.ensureAuthenticated(); - const accessList = await this.apiClient.listProjectIpAccessLists({ + const accessList = await this.apiClient!.listProjectIpAccessLists({ params: { path: { groupId: projectId, diff --git a/src/tools/atlas/inspectCluster.ts b/src/tools/atlas/inspectCluster.ts index c53f850f..4be94d9b 100644 --- a/src/tools/atlas/inspectCluster.ts +++ b/src/tools/atlas/inspectCluster.ts @@ -15,7 +15,7 @@ export class InspectClusterTool extends AtlasToolBase { protected async execute({ projectId, clusterName }: ToolArgs): Promise { await this.ensureAuthenticated(); - const cluster = await this.apiClient.getCluster({ + const cluster = await this.apiClient!.getCluster({ params: { path: { groupId: projectId, diff --git a/src/tools/atlas/listClusters.ts b/src/tools/atlas/listClusters.ts index eda4d420..5141eb55 100644 --- a/src/tools/atlas/listClusters.ts +++ b/src/tools/atlas/listClusters.ts @@ -15,11 +15,11 @@ export class ListClustersTool extends AtlasToolBase { await this.ensureAuthenticated(); if (!projectId) { - const data = await this.apiClient.listClustersForAllProjects(); + const data = await this.apiClient!.listClustersForAllProjects(); return this.formatAllClustersTable(data); } else { - const project = await this.apiClient.getProject({ + const project = await this.apiClient!.getProject({ params: { path: { groupId: projectId, @@ -31,7 +31,7 @@ export class ListClustersTool extends AtlasToolBase { throw new Error(`Project with ID "${projectId}" not found.`); } - const data = await this.apiClient.listClusters({ + const data = await this.apiClient!.listClusters({ params: { path: { groupId: project.id || "", diff --git a/src/tools/atlas/listDBUsers.ts b/src/tools/atlas/listDBUsers.ts index d49d981b..f119a831 100644 --- a/src/tools/atlas/listDBUsers.ts +++ b/src/tools/atlas/listDBUsers.ts @@ -6,7 +6,7 @@ import { DatabaseUserRole, UserScope } from "../../common/atlas/openapi.js"; export class ListDBUsersTool extends AtlasToolBase { protected name = "atlas-list-db-users"; - protected description = "List MongoDB Atlas users"; + protected description = "List MongoDB Atlas database users"; protected argsShape = { projectId: z.string().describe("Atlas project ID to filter DB users"), }; diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index 15c48738..41845730 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -2,7 +2,6 @@ import { ToolBase } from "../tool.js"; import { ApiClient } from "../../common/atlas/apiClient.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { State } from "../../state.js"; -import { AuthTool } from "./auth.js"; import { ListClustersTool } from "./listClusters.js"; import { ListProjectsTool } from "./listProjects.js"; import { InspectClusterTool } from "./inspectCluster.js"; @@ -12,9 +11,8 @@ import { InspectAccessListTool } from "./inspectAccessList.js"; import { ListDBUsersTool } from "./listDBUsers.js"; import { CreateDBUserTool } from "./createDBUser.js"; -export function registerAtlasTools(server: McpServer, state: State, apiClient: ApiClient) { +export function registerAtlasTools(server: McpServer, state: State, apiClient?: ApiClient) { const tools: ToolBase[] = [ - new AuthTool(state, apiClient), new ListClustersTool(state, apiClient), new ListProjectsTool(state, apiClient), new InspectClusterTool(state, apiClient), From 696a5f9a64c7f05a795c88fda74cbb2d067699bd Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 11 Apr 2025 11:30:12 +0100 Subject: [PATCH 2/3] fix: error handling --- README.md | 3 +++ src/tools/mongodb/mongodbTool.ts | 5 +++-- src/tools/tool.ts | 38 +++++++++----------------------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a7f536b3..c234d6a0 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow To use the Atlas API tools, you'll need to create a service account in MongoDB Atlas: 1. **Create a Service Account:** + - Log in to MongoDB Atlas at [cloud.mongodb.com](https://cloud.mongodb.com) - Navigate to Access Manager > Organization Access - Click Add New > Applications > Service Accounts @@ -182,10 +183,12 @@ To use the Atlas API tools, you'll need to create a service account in MongoDB A - Click "Create" 2. **Save Client Credentials:** + - After creation, you'll be shown the Client ID and Client Secret - **Important:** Copy and save the Client Secret immediately as it won't be displayed again 3. **Add Access List Entry (Optional but recommended):** + - Add your IP address to the API access list 4. **Configure the MCP Server:** diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index 2031fe29..d79e9ea7 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -28,7 +28,7 @@ export abstract class MongoDBToolBase extends ToolBase { return provider; } - protected handleError(error: unknown): CallToolResult | undefined { + protected handleError(error: unknown): Promise | CallToolResult { if (error instanceof MongoDBError && error.code === ErrorCodes.NotConnectedToMongoDB) { return { content: [ @@ -41,9 +41,10 @@ export abstract class MongoDBToolBase extends ToolBase { text: "Please use the 'connect' tool to connect to a MongoDB instance.", }, ], + isError: true, }; } - return undefined; + return super.handleError(error); } } diff --git a/src/tools/tool.ts b/src/tools/tool.ts index c8a2d60a..c1889be5 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -32,30 +32,7 @@ export abstract class ToolBase { } catch (error) { logger.error(mongoLogId(1_000_000), "tool", `Error executing ${this.name}: ${error}`); - // If the error is authentication related, suggest using auth tool - if (error instanceof Error && error.message.includes("Not authenticated")) { - return { - content: [ - { type: "text", text: "You need to authenticate before accessing Atlas data." }, - { - type: "text", - text: "Please use the 'auth' tool to log in to your MongoDB Atlas account.", - }, - ], - }; - } - - return ( - this.handleError(error) || { - content: [ - { - type: "text", - text: `Error running ${this.name}: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - } - ); + return await this.handleError(error); } }; @@ -70,8 +47,15 @@ export abstract class ToolBase { } // This method is intended to be overridden by subclasses to handle errors - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected handleError(error: unknown): CallToolResult | undefined { - return undefined; + protected handleError(error: unknown): Promise | CallToolResult { + return { + content: [ + { + type: "text", + text: `Error running ${this.name}: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; } } From 520710eaf9152989995c94b177a58d3e2cdb7ac5 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Fri, 11 Apr 2025 11:54:21 +0100 Subject: [PATCH 3/3] fix: address comments --- README.md | 11 +++++------ src/tools/atlas/atlasTool.ts | 2 +- src/tools/atlas/createAccessList.ts | 2 +- src/tools/atlas/createDBUser.ts | 2 +- src/tools/atlas/createFreeCluster.ts | 2 +- src/tools/atlas/inspectAccessList.ts | 2 +- src/tools/atlas/inspectCluster.ts | 2 +- src/tools/atlas/listClusters.ts | 2 +- src/tools/atlas/listDBUsers.ts | 2 +- src/tools/atlas/listProjects.ts | 2 +- 10 files changed, 14 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c234d6a0..b8d4eedd 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,11 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow ### Configuration Options -| Option | Description | -| ------------------ | --------------------------------------------------------------------------- | -| `apiClientId` | Atlas API client ID for authentication | -| `apiClientSecret` | Atlas API client secret for authentication | -| `stateFile` | Path to store application state (default ~/.mongodb/mongodb-mcp/state.json) | -| `connectionString` | MongoDB connection string for direct database connections | +| Option | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `apiClientId` | Atlas API client ID for authentication | +| `apiClientSecret` | Atlas API client secret for authentication | +| `connectionString` | MongoDB connection string for direct database connections (optional users may choose to inform it on every tool call) | ### Atlas API Access diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index af759b82..0b40d088 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -10,7 +10,7 @@ export abstract class AtlasToolBase extends ToolBase { super(state); } - protected async ensureAuthenticated(): Promise { + protected ensureAuthenticated(): void { if (!this.apiClient) { throw new Error( "Not authenticated make sure to configure MCP server with MDB_MCP_API_CLIENT_ID and MDB_MCP_API_CLIENT_SECRET environment variables." diff --git a/src/tools/atlas/createAccessList.ts b/src/tools/atlas/createAccessList.ts index d136404f..f91509a4 100644 --- a/src/tools/atlas/createAccessList.ts +++ b/src/tools/atlas/createAccessList.ts @@ -26,7 +26,7 @@ export class CreateAccessListTool extends AtlasToolBase { comment, currentIpAddress, }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); if (!ipAddresses?.length && !cidrBlocks?.length && !currentIpAddress) { throw new Error("One of ipAddresses, cidrBlocks, currentIpAddress must be provided."); diff --git a/src/tools/atlas/createDBUser.ts b/src/tools/atlas/createDBUser.ts index b9112291..fe605a0f 100644 --- a/src/tools/atlas/createDBUser.ts +++ b/src/tools/atlas/createDBUser.ts @@ -33,7 +33,7 @@ export class CreateDBUserTool extends AtlasToolBase { roles, clusters, }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const input = { groupId: projectId, diff --git a/src/tools/atlas/createFreeCluster.ts b/src/tools/atlas/createFreeCluster.ts index 4d9e7a89..5339c5d2 100644 --- a/src/tools/atlas/createFreeCluster.ts +++ b/src/tools/atlas/createFreeCluster.ts @@ -14,7 +14,7 @@ export class CreateFreeClusterTool extends AtlasToolBase { }; protected async execute({ projectId, name, region }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const input = { groupId: projectId, diff --git a/src/tools/atlas/inspectAccessList.ts b/src/tools/atlas/inspectAccessList.ts index 4a6c8522..d1497ee6 100644 --- a/src/tools/atlas/inspectAccessList.ts +++ b/src/tools/atlas/inspectAccessList.ts @@ -11,7 +11,7 @@ export class InspectAccessListTool extends AtlasToolBase { }; protected async execute({ projectId }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const accessList = await this.apiClient!.listProjectIpAccessLists({ params: { diff --git a/src/tools/atlas/inspectCluster.ts b/src/tools/atlas/inspectCluster.ts index 4be94d9b..f0bda92d 100644 --- a/src/tools/atlas/inspectCluster.ts +++ b/src/tools/atlas/inspectCluster.ts @@ -13,7 +13,7 @@ export class InspectClusterTool extends AtlasToolBase { }; protected async execute({ projectId, clusterName }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const cluster = await this.apiClient!.getCluster({ params: { diff --git a/src/tools/atlas/listClusters.ts b/src/tools/atlas/listClusters.ts index 5141eb55..31dea9e5 100644 --- a/src/tools/atlas/listClusters.ts +++ b/src/tools/atlas/listClusters.ts @@ -12,7 +12,7 @@ export class ListClustersTool extends AtlasToolBase { }; protected async execute({ projectId }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); if (!projectId) { const data = await this.apiClient!.listClustersForAllProjects(); diff --git a/src/tools/atlas/listDBUsers.ts b/src/tools/atlas/listDBUsers.ts index f119a831..46404123 100644 --- a/src/tools/atlas/listDBUsers.ts +++ b/src/tools/atlas/listDBUsers.ts @@ -12,7 +12,7 @@ export class ListDBUsersTool extends AtlasToolBase { }; protected async execute({ projectId }: ToolArgs): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const data = await this.apiClient!.listDatabaseUsers({ params: { diff --git a/src/tools/atlas/listProjects.ts b/src/tools/atlas/listProjects.ts index 6b4b7d4a..d6b16f89 100644 --- a/src/tools/atlas/listProjects.ts +++ b/src/tools/atlas/listProjects.ts @@ -7,7 +7,7 @@ export class ListProjectsTool extends AtlasToolBase { protected argsShape = {}; protected async execute(): Promise { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); const data = await this.apiClient!.listProjects();