Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@redocly/cli": "^1.34.2",
"@types/node": "^22.14.0",
"@types/simple-oauth2": "^5.0.7",
"@types/yargs-parser": "^21.0.3",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1",
"globals": "^16.0.0",
Expand All @@ -58,6 +59,7 @@
"mongodb-log-writer": "^2.4.1",
"mongodb-redact": "^1.1.6",
"mongodb-schema": "^12.6.2",
"yargs-parser": "^21.1.1",
"zod": "^3.24.2"
},
"engines": {
Expand Down
18 changes: 9 additions & 9 deletions src/common/atlas/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class ApiClient {
throw new Error("Not authenticated. Please run the auth tool first.");
}

const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseURL}`);
const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseUrl}`);

if (!this.checkTokenExpiry()) {
await this.refreshToken();
Expand Down Expand Up @@ -119,7 +119,7 @@ export class ApiClient {
async authenticate(): Promise<OauthDeviceCode> {
const endpoint = "api/private/unauth/account/device/authorize";

const authUrl = new URL(endpoint, config.apiBaseURL);
const authUrl = new URL(endpoint, config.apiBaseUrl);

const response = await fetch(authUrl, {
method: "POST",
Expand All @@ -128,7 +128,7 @@ export class ApiClient {
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientID,
client_id: config.clientId,
scope: "openid profile offline_access",
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
Expand All @@ -143,14 +143,14 @@ export class ApiClient {

async retrieveToken(device_code: string): Promise<OAuthToken> {
const endpoint = "api/private/unauth/account/device/token";
const url = new URL(endpoint, config.apiBaseURL);
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,
client_id: config.clientId,
device_code: device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}).toString(),
Expand Down Expand Up @@ -179,15 +179,15 @@ export class ApiClient {

async refreshToken(token?: OAuthToken): Promise<OAuthToken | null> {
const endpoint = "api/private/unauth/account/device/token";
const url = new URL(endpoint, config.apiBaseURL);
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,
client_id: config.clientId,
refresh_token: (token || this.token)?.refresh_token || "",
grant_type: "refresh_token",
scope: "openid profile offline_access",
Expand All @@ -213,7 +213,7 @@ export class ApiClient {

async revokeToken(token?: OAuthToken): Promise<void> {
const endpoint = "api/private/unauth/account/device/token";
const url = new URL(endpoint, config.apiBaseURL);
const url = new URL(endpoint, config.apiBaseUrl);
const response = await fetch(url, {
method: "POST",
headers: {
Expand All @@ -222,7 +222,7 @@ export class ApiClient {
"User-Agent": config.userAgent,
},
body: new URLSearchParams({
client_id: config.clientID,
client_id: config.clientId,
token: (token || this.token)?.access_token || "",
token_type_hint: "refresh_token",
}).toString(),
Expand Down
77 changes: 72 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,45 @@ import fs from "fs";
import { fileURLToPath } from "url";
import os from "os";

import argv from "yargs-parser";

// If we decide to support non-string config options, we'll need to extend the mechanism for parsing
// env variables.
interface UserConfig extends Record<string, string> {
apiBaseUrl: string;
clientId: string;
stateFile: string;
projectId: string;
}

const cliConfig = argv(process.argv.slice(2)) as unknown as Partial<UserConfig>;

const defaults: UserConfig = {
apiBaseUrl: "https://cloud.mongodb.com/",
clientId: "0oabtxactgS3gHIR0297",
stateFile: path.resolve("./state.json"),
projectId: "",
};

const mergedUserConfig = mergeConfigs(defaults, getFileConfig(), getEnvConfig(), cliConfig);

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const packageMetadata = fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8");
const packageJson = JSON.parse(packageMetadata);

export const config = {
const config = {
...mergedUserConfig,
atlasApiVersion: `2025-03-12`,
version: packageJson.version,
apiBaseURL: process.env.API_BASE_URL || "https://cloud.mongodb.com/",
clientID: process.env.CLIENT_ID || "0oabtxactgS3gHIR0297",
stateFile: process.env.STATE_FILE || path.resolve("./state.json"),
userAgent: `AtlasMCP/${packageJson.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
localDataPath: getLocalDataPath(),
};

export default config;

function getLocalDataPath() {
function getLocalDataPath(): string {
if (process.platform === "win32") {
const appData = process.env.APPDATA;
const localAppData = process.env.LOCALAPPDATA ?? process.env.APPDATA;
Expand All @@ -32,3 +52,50 @@ function getLocalDataPath() {

return path.join(os.homedir(), ".mongodb", "mongodb-mcp");
}

// Gets the config supplied by the user as environment variables. The variable names
// are prefixed with `MDB_MCP_` and the keys match the UserConfig keys, but are converted
// to SNAKE_UPPER_CASE.
function getEnvConfig(): Partial<UserConfig> {
const camelCaseToSNAKE_UPPER_CASE = (str: string): string => {
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
};

const result: Partial<UserConfig> = {};
Object.keys(defaults).forEach((key) => {
const envVarName = `MDB_MCP_${camelCaseToSNAKE_UPPER_CASE(key)}`;
if (process.env[envVarName]) {
result[key as keyof UserConfig] = process.env[envVarName];
}
});

return result;
}

// Gets the config supplied by the user as a JSON file. The file is expected to be located in the local data path
// and named `config.json`.
function getFileConfig(): Partial<UserConfig> {
const configPath = path.join(getLocalDataPath(), "config.json");

try {
const config = fs.readFileSync(configPath, "utf8");
return JSON.parse(config);
} catch {
return {};
}
}

// Merges several user-supplied configs into one. The precedence is from right to left where the last
// config in the `partialConfigs` array overrides the previous ones. The `defaults` config is used as a base.
function mergeConfigs(defaults: UserConfig, ...partialConfigs: Array<Partial<UserConfig>>): UserConfig {
const mergedConfig: UserConfig = { ...defaults };
for (const key of Object.keys(defaults)) {
for (const partialConfig of partialConfigs) {
if (partialConfig[key]) {
mergedConfig[key] = partialConfig[key];
}
}
}

return mergedConfig;
}
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { State, saveState, loadState } from "./state.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { registerAtlasTools } from "./tools/atlas/tools.js";
import { registerMongoDBTools } from "./tools/mongodb/index.js";
import { config } from "./config.js";
import config from "./config.js";
import logger, { initializeLogger } from "./logger.js";
import { mongoLogId } from "mongodb-log-writer";

Expand Down