• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mongodb-js / mongodb-mcp-server / 18503133363

14 Oct 2025 04:20PM UTC coverage: 82.293% (+0.08%) from 82.215%
18503133363

Pull #645

github

web-flow
Merge e716cae1e into 8e3c6a650
Pull Request #645: feat: atlas-get-performance-advisor tool: tweak language for slow queries

1165 of 1544 branches covered (75.45%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 2 files covered. (100.0%)

7 existing lines in 1 file now uncovered.

5667 of 6758 relevant lines covered (83.86%)

69.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.25
/src/common/config.ts
1
import path from "path";
2✔
2
import os from "os";
2✔
3
import argv from "yargs-parser";
2✔
4
import type { CliOptions, ConnectionInfo } from "@mongosh/arg-parser";
5
import { generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser";
2✔
6
import { Keychain } from "./keychain.js";
2✔
7
import type { Secret } from "./keychain.js";
8
import levenshtein from "ts-levenshtein";
2✔
9

10
// From: https://github.com/mongodb-js/mongosh/blob/main/packages/cli-repl/src/arg-parser.ts
11
const OPTIONS = {
2✔
12
    number: ["maxDocumentsPerQuery", "maxBytesPerQuery"],
2✔
13
    string: [
2✔
14
        "apiBaseUrl",
2✔
15
        "apiClientId",
2✔
16
        "apiClientSecret",
2✔
17
        "connectionString",
2✔
18
        "httpHost",
2✔
19
        "httpPort",
2✔
20
        "idleTimeoutMs",
2✔
21
        "logPath",
2✔
22
        "notificationTimeoutMs",
2✔
23
        "telemetry",
2✔
24
        "transport",
2✔
25
        "apiVersion",
2✔
26
        "authenticationDatabase",
2✔
27
        "authenticationMechanism",
2✔
28
        "browser",
2✔
29
        "db",
2✔
30
        "gssapiHostName",
2✔
31
        "gssapiServiceName",
2✔
32
        "host",
2✔
33
        "oidcFlows",
2✔
34
        "oidcRedirectUri",
2✔
35
        "password",
2✔
36
        "port",
2✔
37
        "sslCAFile",
2✔
38
        "sslCRLFile",
2✔
39
        "sslCertificateSelector",
2✔
40
        "sslDisabledProtocols",
2✔
41
        "sslPEMKeyFile",
2✔
42
        "sslPEMKeyPassword",
2✔
43
        "sspiHostnameCanonicalization",
2✔
44
        "sspiRealmOverride",
2✔
45
        "tlsCAFile",
2✔
46
        "tlsCRLFile",
2✔
47
        "tlsCertificateKeyFile",
2✔
48
        "tlsCertificateKeyFilePassword",
2✔
49
        "tlsCertificateSelector",
2✔
50
        "tlsDisabledProtocols",
2✔
51
        "username",
2✔
52
        "atlasTemporaryDatabaseUserLifetimeMs",
2✔
53
        "exportsPath",
2✔
54
        "exportTimeoutMs",
2✔
55
        "exportCleanupIntervalMs",
2✔
56
        "voyageApiKey",
2✔
57
    ],
2✔
58
    boolean: [
2✔
59
        "apiDeprecationErrors",
2✔
60
        "apiStrict",
2✔
61
        "help",
2✔
62
        "indexCheck",
2✔
63
        "ipv6",
2✔
64
        "nodb",
2✔
65
        "oidcIdTokenAsAccessToken",
2✔
66
        "oidcNoNonce",
2✔
67
        "oidcTrustedEndpoint",
2✔
68
        "readOnly",
2✔
69
        "retryWrites",
2✔
70
        "ssl",
2✔
71
        "sslAllowInvalidCertificates",
2✔
72
        "sslAllowInvalidHostnames",
2✔
73
        "sslFIPSMode",
2✔
74
        "tls",
2✔
75
        "tlsAllowInvalidCertificates",
2✔
76
        "tlsAllowInvalidHostnames",
2✔
77
        "tlsFIPSMode",
2✔
78
        "version",
2✔
79
    ],
2✔
80
    array: ["disabledTools", "loggers", "confirmationRequiredTools"],
2✔
81
    alias: {
2✔
82
        h: "help",
2✔
83
        p: "password",
2✔
84
        u: "username",
2✔
85
        "build-info": "buildInfo",
2✔
86
        browser: "browser",
2✔
87
        oidcDumpTokens: "oidcDumpTokens",
2✔
88
        oidcRedirectUrl: "oidcRedirectUri",
2✔
89
        oidcIDTokenAsAccessToken: "oidcIdTokenAsAccessToken",
2✔
90
    },
2✔
91
    configuration: {
2✔
92
        "camel-case-expansion": false,
2✔
93
        "unknown-options-as-args": true,
2✔
94
        "parse-positional-numbers": false,
2✔
95
        "parse-numbers": false,
2✔
96
        "greedy-arrays": true,
2✔
97
        "short-option-groups": false,
2✔
98
    },
2✔
99
} as Readonly<Options>;
2✔
100

101
interface Options {
102
    string: string[];
103
    number: string[];
104
    boolean: string[];
105
    array: string[];
106
    alias: Record<string, string>;
107
    configuration: Record<string, boolean>;
108
}
109

110
export const ALL_CONFIG_KEYS = new Set(
2✔
111
    (OPTIONS.string as readonly string[])
2✔
112
        .concat(OPTIONS.number)
2✔
113
        .concat(OPTIONS.array)
2✔
114
        .concat(OPTIONS.boolean)
2✔
115
        .concat(Object.keys(OPTIONS.alias))
2✔
116
);
2✔
117

118
export function validateConfigKey(key: string): { valid: boolean; suggestion?: string } {
2✔
119
    if (ALL_CONFIG_KEYS.has(key)) {
95✔
120
        return { valid: true };
91✔
121
    }
91✔
122

123
    let minLev = Number.MAX_VALUE;
4✔
124
    let suggestion = "";
4✔
125

126
    // find the closest match for a suggestion
127
    for (const validKey of ALL_CONFIG_KEYS) {
95✔
128
        // check if there is an exact case-insensitive match
129
        if (validKey.toLowerCase() === key.toLowerCase()) {
283✔
130
            return { valid: false, suggestion: validKey };
1✔
131
        }
1✔
132

133
        // else, infer something using levenshtein so we suggest a valid key
134
        const lev = levenshtein.get(key, validKey);
282✔
135
        if (lev < minLev) {
283✔
136
            minLev = lev;
17✔
137
            suggestion = validKey;
17✔
138
        }
17✔
139
    }
283✔
140

141
    if (minLev <= 2) {
95✔
142
        // accept up to 2 typos
143
        return { valid: false, suggestion };
1✔
144
    }
1✔
145

146
    return { valid: false };
2✔
147
}
2✔
148

149
function isConnectionSpecifier(arg: string | undefined): boolean {
153✔
150
    return (
153✔
151
        arg !== undefined &&
153!
152
        (arg.startsWith("mongodb://") ||
1!
153
            arg.startsWith("mongodb+srv://") ||
×
154
            !(arg.endsWith(".js") || arg.endsWith(".mongodb")))
×
155
    );
156
}
153✔
157

158
// If we decide to support non-string config options, we'll need to extend the mechanism for parsing
159
// env variables.
160
export interface UserConfig extends CliOptions {
161
    apiBaseUrl: string;
162
    apiClientId?: string;
163
    apiClientSecret?: string;
164
    telemetry: "enabled" | "disabled";
165
    logPath: string;
166
    exportsPath: string;
167
    exportTimeoutMs: number;
168
    exportCleanupIntervalMs: number;
169
    connectionString?: string;
170
    // TODO: Use a type tracking all tool names.
171
    disabledTools: Array<string>;
172
    confirmationRequiredTools: Array<string>;
173
    readOnly?: boolean;
174
    indexCheck?: boolean;
175
    transport: "stdio" | "http";
176
    httpPort: number;
177
    httpHost: string;
178
    httpHeaders: Record<string, string>;
179
    loggers: Array<"stderr" | "disk" | "mcp">;
180
    idleTimeoutMs: number;
181
    notificationTimeoutMs: number;
182
    maxDocumentsPerQuery: number;
183
    maxBytesPerQuery: number;
184
    atlasTemporaryDatabaseUserLifetimeMs: number;
185
    voyageApiKey: string;
186
}
187

188
export const defaultUserConfig: UserConfig = {
2✔
189
    apiBaseUrl: "https://cloud.mongodb.com/",
2✔
190
    logPath: getLogPath(),
2✔
191
    exportsPath: getExportsPath(),
2✔
192
    exportTimeoutMs: 5 * 60 * 1000, // 5 minutes
2✔
193
    exportCleanupIntervalMs: 2 * 60 * 1000, // 2 minutes
2✔
194
    disabledTools: [],
2✔
195
    telemetry: "enabled",
2✔
196
    readOnly: false,
2✔
197
    indexCheck: false,
2✔
198
    confirmationRequiredTools: [
2✔
199
        "atlas-create-access-list",
2✔
200
        "atlas-create-db-user",
2✔
201
        "drop-database",
2✔
202
        "drop-collection",
2✔
203
        "delete-many",
2✔
204
        "drop-index",
2✔
205
    ],
2✔
206
    transport: "stdio",
2✔
207
    httpPort: 3000,
2✔
208
    httpHost: "127.0.0.1",
2✔
209
    loggers: ["disk", "mcp"],
2✔
210
    idleTimeoutMs: 10 * 60 * 1000, // 10 minutes
2✔
211
    notificationTimeoutMs: 9 * 60 * 1000, // 9 minutes
2✔
212
    httpHeaders: {},
2✔
213
    maxDocumentsPerQuery: 100, // By default, we only fetch a maximum 100 documents per query / aggregation
2✔
214
    maxBytesPerQuery: 16 * 1024 * 1024, // By default, we only return ~16 mb of data per query / aggregation
2✔
215
    atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours
2✔
216
    voyageApiKey: "",
2✔
217
};
2✔
218

219
export const config = setupUserConfig({
2✔
220
    defaults: defaultUserConfig,
2✔
221
    cli: process.argv,
2✔
222
    env: process.env,
2✔
223
});
2✔
224

225
function getLocalDataPath(): string {
106✔
226
    return process.platform === "win32"
106!
UNCOV
227
        ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || os.homedir(), "mongodb")
×
228
        : path.join(os.homedir(), ".mongodb");
106✔
229
}
106✔
230

231
export type DriverOptions = ConnectionInfo["driverOptions"];
232
export const defaultDriverOptions: DriverOptions = {
2✔
233
    readConcern: {
2✔
234
        level: "local",
2✔
235
    },
2✔
236
    readPreference: "secondaryPreferred",
2✔
237
    writeConcern: {
2✔
238
        w: "majority",
2✔
239
    },
2✔
240
    timeoutMS: 30_000,
2✔
241
    proxy: { useEnvironmentVariableProxies: true },
2✔
242
    applyProxyToOIDC: true,
2✔
243
};
2✔
244

245
function getLogPath(): string {
53✔
246
    const logPath = path.join(getLocalDataPath(), "mongodb-mcp", ".app-logs");
53✔
247
    return logPath;
53✔
248
}
53✔
249

250
function getExportsPath(): string {
53✔
251
    return path.join(getLocalDataPath(), "mongodb-mcp", "exports");
53✔
252
}
53✔
253

254
// Gets the config supplied by the user as environment variables. The variable names
255
// are prefixed with `MDB_MCP_` and the keys match the UserConfig keys, but are converted
256
// to SNAKE_UPPER_CASE.
257
function parseEnvConfig(env: Record<string, unknown>): Partial<UserConfig> {
154✔
258
    const CONFIG_WITH_URLS: Set<string> = new Set<(typeof OPTIONS)["string"][number]>(["connectionString"]);
154✔
259

260
    function setValue(obj: Record<string, unknown>, path: string[], value: string): void {
154✔
261
        const currentField = path.shift();
40✔
262
        if (!currentField) {
40!
263
            return;
×
UNCOV
264
        }
×
265
        if (path.length === 0) {
40✔
266
            if (CONFIG_WITH_URLS.has(currentField)) {
40!
267
                obj[currentField] = value;
4✔
268
                return;
4✔
269
            }
4✔
270

271
            const numberValue = Number(value);
36✔
272
            if (!isNaN(numberValue)) {
40!
273
                obj[currentField] = numberValue;
4✔
274
                return;
4✔
275
            }
4✔
276

277
            const booleanValue = value.toLocaleLowerCase();
32✔
278
            if (booleanValue === "true" || booleanValue === "false") {
40!
279
                obj[currentField] = booleanValue === "true";
2✔
280
                return;
2✔
281
            }
2✔
282

283
            // Try to parse an array of values
284
            if (value.indexOf(",") !== -1) {
40!
285
                obj[currentField] = value.split(",").map((v) => v.trim());
2✔
286
                return;
2✔
287
            }
2✔
288

289
            obj[currentField] = value;
28✔
290
            return;
28✔
291
        }
28!
292

293
        if (!obj[currentField]) {
×
294
            obj[currentField] = {};
×
UNCOV
295
        }
×
296

UNCOV
297
        setValue(obj[currentField] as Record<string, unknown>, path, value);
×
298
    }
40✔
299

300
    const result: Record<string, unknown> = {};
154✔
301
    const mcpVariables = Object.entries(env).filter(
154✔
302
        ([key, value]) => value !== undefined && key.startsWith("MDB_MCP_")
154✔
303
    ) as [string, string][];
154✔
304
    for (const [key, value] of mcpVariables) {
154✔
305
        const fieldPath = key
40✔
306
            .replace("MDB_MCP_", "")
40✔
307
            .split(".")
40✔
308
            .map((part) => SNAKE_CASE_toCamelCase(part));
40✔
309

310
        setValue(result, fieldPath, value);
40✔
311
    }
40✔
312

313
    return result;
154✔
314
}
154✔
315

316
function SNAKE_CASE_toCamelCase(str: string): string {
40✔
317
    return str.toLowerCase().replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("_", ""));
40✔
318
}
40✔
319

320
// Right now we have arguments that are not compatible with the format used in mongosh.
321
// An example is using --connectionString and positional arguments.
322
// We will consolidate them in a way where the mongosh format takes precedence.
323
// We will warn users that previous configuration is deprecated in favour of
324
// whatever is in mongosh.
325
function parseCliConfig(args: string[]): CliOptions {
154✔
326
    const programArgs = args.slice(2);
154✔
327
    const parsed = argv(programArgs, OPTIONS as unknown as argv.Options) as unknown as CliOptions &
154✔
328
        UserConfig & {
329
            _?: string[];
330
        };
331

332
    const positionalArguments = parsed._ ?? [];
154!
333

334
    // we use console.warn here because we still don't have our logging system configured
335
    // so we don't have a logger. For stdio, the warning will be received as a string in
336
    // the client and IDEs like VSCode do show the message in the log window. For HTTP,
337
    // it will be in the stdout of the process.
338
    warnAboutDeprecatedOrUnknownCliArgs(
154✔
339
        { ...parsed, _: positionalArguments },
154✔
340
        {
154✔
341
            warn: (msg) => console.warn(msg),
154✔
342
            exit: (status) => process.exit(status),
154✔
343
        }
154✔
344
    );
154✔
345

346
    // if we have a positional argument that matches a connection string
347
    // store it as the connection specifier and remove it from the argument
348
    // list, so it doesn't get misunderstood by the mongosh args-parser
349
    if (!parsed.nodb && isConnectionSpecifier(positionalArguments[0])) {
154!
350
        parsed.connectionSpecifier = positionalArguments.shift();
1✔
351
    }
1✔
352

353
    delete parsed._;
154✔
354
    return parsed;
154✔
355
}
154✔
356

357
export function warnAboutDeprecatedOrUnknownCliArgs(
2✔
358
    args: Record<string, unknown>,
161✔
359
    { warn, exit }: { warn: (msg: string) => void; exit: (status: number) => void | never }
161✔
360
): void {
161✔
361
    let usedDeprecatedArgument = false;
161✔
362
    let usedInvalidArgument = false;
161✔
363

364
    const knownArgs = args as unknown as UserConfig & CliOptions;
161✔
365
    // the first position argument should be used
366
    // instead of --connectionString, as it's how the mongosh works.
367
    if (knownArgs.connectionString) {
161!
368
        usedDeprecatedArgument = true;
8✔
369
        warn(
8✔
370
            "The --connectionString argument is deprecated. Prefer using the MDB_MCP_CONNECTION_STRING environment variable or the first positional argument for the connection string."
8✔
371
        );
8✔
372
    }
8✔
373

374
    for (const providedKey of Object.keys(args)) {
161✔
375
        if (providedKey === "_") {
249✔
376
            // positional argument
377
            continue;
154✔
378
        }
154!
379

380
        const { valid, suggestion } = validateConfigKey(providedKey);
95✔
381
        if (!valid) {
197✔
382
            usedInvalidArgument = true;
4✔
383
            if (suggestion) {
4✔
384
                warn(`Invalid command line argument '${providedKey}'. Did you mean '${suggestion}'?`);
2✔
385
            } else {
2✔
386
                warn(`Invalid command line argument '${providedKey}'.`);
2✔
387
            }
2✔
388
        }
4✔
389
    }
249✔
390

391
    if (usedInvalidArgument || usedDeprecatedArgument) {
161!
392
        warn("Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server.");
12✔
393
    }
12✔
394

395
    if (usedInvalidArgument) {
161!
396
        exit(1);
4✔
397
    }
4✔
398
}
161✔
399

400
function commaSeparatedToArray<T extends string[]>(str: string | string[] | undefined): T {
462✔
401
    if (str === undefined) {
462!
402
        return [] as unknown as T;
×
UNCOV
403
    }
×
404

405
    if (!Array.isArray(str)) {
462!
406
        return [str] as T;
×
UNCOV
407
    }
×
408

409
    if (str.length === 0) {
462✔
410
        return str as T;
152✔
411
    }
152✔
412

413
    if (str.length === 1) {
462!
414
        return str[0]
7✔
415
            ?.split(",")
7✔
416
            .map((e) => e.trim())
7✔
417
            .filter((e) => e.length > 0) as T;
7✔
418
    }
7✔
419

420
    return str as T;
303✔
421
}
303✔
422

423
export function registerKnownSecretsInRootKeychain(userConfig: Partial<UserConfig>): void {
2✔
424
    const keychain = Keychain.root;
156✔
425

426
    const maybeRegister = (value: string | undefined, kind: Secret["kind"]): void => {
156✔
427
        if (value) {
1,872✔
428
            keychain.register(value, kind);
36✔
429
        }
36✔
430
    };
1,872✔
431

432
    maybeRegister(userConfig.apiClientId, "user");
156✔
433
    maybeRegister(userConfig.apiClientSecret, "password");
156✔
434
    maybeRegister(userConfig.awsAccessKeyId, "password");
156✔
435
    maybeRegister(userConfig.awsIamSessionToken, "password");
156✔
436
    maybeRegister(userConfig.awsSecretAccessKey, "password");
156✔
437
    maybeRegister(userConfig.awsSessionToken, "password");
156✔
438
    maybeRegister(userConfig.password, "password");
156✔
439
    maybeRegister(userConfig.tlsCAFile, "url");
156✔
440
    maybeRegister(userConfig.tlsCRLFile, "url");
156✔
441
    maybeRegister(userConfig.tlsCertificateKeyFile, "url");
156✔
442
    maybeRegister(userConfig.tlsCertificateKeyFilePassword, "password");
156✔
443
    maybeRegister(userConfig.username, "user");
156✔
444
}
156✔
445

446
export function setupUserConfig({
2✔
447
    cli,
154✔
448
    env,
154✔
449
    defaults,
154✔
450
}: {
154✔
451
    cli: string[];
452
    env: Record<string, unknown>;
453
    defaults: Partial<UserConfig>;
454
}): UserConfig {
154✔
455
    const userConfig: UserConfig = {
154✔
456
        ...defaults,
154✔
457
        ...parseEnvConfig(env),
154✔
458
        ...parseCliConfig(cli),
154✔
459
    } as UserConfig;
154✔
460

461
    userConfig.disabledTools = commaSeparatedToArray(userConfig.disabledTools);
154✔
462
    userConfig.loggers = commaSeparatedToArray(userConfig.loggers);
154✔
463
    userConfig.confirmationRequiredTools = commaSeparatedToArray(userConfig.confirmationRequiredTools);
154✔
464

465
    if (userConfig.connectionString && userConfig.connectionSpecifier) {
154!
466
        const connectionInfo = generateConnectionInfoFromCliArgs(userConfig);
1✔
467
        userConfig.connectionString = connectionInfo.connectionString;
1✔
468
    }
1✔
469

470
    const transport = userConfig.transport as string;
154✔
471
    if (transport !== "http" && transport !== "stdio") {
154!
472
        throw new Error(`Invalid transport: ${transport}`);
2✔
473
    }
2✔
474

475
    const telemetry = userConfig.telemetry as string;
152✔
476
    if (telemetry !== "enabled" && telemetry !== "disabled") {
154!
477
        throw new Error(`Invalid telemetry: ${telemetry}`);
3✔
478
    }
3✔
479

480
    const httpPort = +userConfig.httpPort;
149✔
481
    if (httpPort < 1 || httpPort > 65535 || isNaN(httpPort)) {
154!
482
        throw new Error(`Invalid httpPort: ${userConfig.httpPort}`);
3✔
483
    }
3✔
484

485
    if (userConfig.loggers.length === 0) {
154!
486
        throw new Error("No loggers found in config");
1✔
487
    }
1✔
488

489
    const loggerTypes = new Set(userConfig.loggers);
145✔
490
    if (loggerTypes.size !== userConfig.loggers.length) {
154!
491
        throw new Error("Duplicate loggers found in config");
1✔
492
    }
1✔
493

494
    for (const loggerType of userConfig.loggers as string[]) {
154✔
495
        if (loggerType !== "mcp" && loggerType !== "disk" && loggerType !== "stderr") {
285!
496
            throw new Error(`Invalid logger: ${loggerType}`);
×
UNCOV
497
        }
×
498
    }
285✔
499

500
    registerKnownSecretsInRootKeychain(userConfig);
144✔
501
    return userConfig;
144✔
502
}
144✔
503

504
export function setupDriverConfig({
2✔
505
    config,
60✔
506
    defaults,
60✔
507
}: {
60✔
508
    config: UserConfig;
509
    defaults: Partial<DriverOptions>;
510
}): DriverOptions {
60✔
511
    const { driverOptions } = generateConnectionInfoFromCliArgs(config);
60✔
512
    return {
60✔
513
        ...defaults,
60✔
514
        ...driverOptions,
60✔
515
    };
60✔
516
}
60✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc