diff --git a/package-lock.json b/package-lock.json index 1385e8ca..290e9600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "node-machine-id": "1.1.12", "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", + "ts-levenshtein": "^1.0.7", "yargs-parser": "^22.0.0", "zod": "^3.25.76" }, @@ -8407,12 +8408,6 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "license": "MIT" - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -11412,6 +11407,12 @@ "node": ">= 0.8.0" } }, + "node_modules/optionator/node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, "node_modules/os-dns-native": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/os-dns-native/-/os-dns-native-1.2.1.tgz", @@ -14343,6 +14344,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-levenshtein": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ts-levenshtein/-/ts-levenshtein-1.0.7.tgz", + "integrity": "sha512-wautEf7gl2ITJuRTTYxnlrLjzUUcwFSdg46bcu4RlzoE/zQM++TJjBFRf2Xhil49GiHqKCqmpjf1lBkWnAHj0A==", + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", diff --git a/package.json b/package.json index cc7e68cc..7a9fc15f 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "node-machine-id": "1.1.12", "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", + "ts-levenshtein": "^1.0.7", "yargs-parser": "^22.0.0", "zod": "^3.25.76" }, diff --git a/src/common/config.ts b/src/common/config.ts index 9fa78ec0..9132a6c6 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -5,6 +5,7 @@ import type { CliOptions, ConnectionInfo } from "@mongosh/arg-parser"; import { generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser"; import { Keychain } from "./keychain.js"; import type { Secret } from "./keychain.js"; +import levenshtein from "ts-levenshtein"; // From: https://github.com/mongodb-js/mongosh/blob/main/packages/cli-repl/src/arg-parser.ts const OPTIONS = { @@ -91,6 +92,44 @@ const OPTIONS = { }, } as const; +const ALL_CONFIG_KEYS = new Set( + (OPTIONS.string as readonly string[]) + .concat(OPTIONS.array) + .concat(OPTIONS.boolean) + .concat(Object.keys(OPTIONS.alias)) +); + +export function validateConfigKey(key: string): { valid: boolean; suggestion?: string } { + if (ALL_CONFIG_KEYS.has(key)) { + return { valid: true }; + } + + let minLev = Number.MAX_VALUE; + let suggestion = ""; + + // find the closest match for a suggestion + for (const validKey of ALL_CONFIG_KEYS) { + // check if there is an exact case-insensitive match + if (validKey.toLowerCase() === key.toLowerCase()) { + return { valid: false, suggestion: validKey }; + } + + // else, infer something using levenshtein so we suggest a valid key + const lev = levenshtein.get(key, validKey); + if (lev < minLev) { + minLev = lev; + suggestion = validKey; + } + } + + if (minLev <= 2) { + // accept up to 2 typos + return { valid: false, suggestion }; + } + + return { valid: false }; +} + function isConnectionSpecifier(arg: string | undefined): boolean { return ( arg !== undefined && @@ -267,7 +306,13 @@ function parseCliConfig(args: string[]): CliOptions { // so we don't have a logger. For stdio, the warning will be received as a string in // the client and IDEs like VSCode do show the message in the log window. For HTTP, // it will be in the stdout of the process. - warnAboutDeprecatedCliArgs({ ...parsed, _: positionalArguments }, console.warn); + warnAboutDeprecatedOrUnknownCliArgs( + { ...parsed, _: positionalArguments }, + { + warn: (msg) => console.warn(msg), + exit: (status) => process.exit(status), + } + ); // if we have a positional argument that matches a connection string // store it as the connection specifier and remove it from the argument @@ -280,26 +325,47 @@ function parseCliConfig(args: string[]): CliOptions { return parsed; } -export function warnAboutDeprecatedCliArgs( - args: CliOptions & - UserConfig & { - _?: string[]; - }, - warn: (msg: string) => void +export function warnAboutDeprecatedOrUnknownCliArgs( + args: Record, + { warn, exit }: { warn: (msg: string) => void; exit: (status: number) => void | never } ): void { let usedDeprecatedArgument = false; + let usedInvalidArgument = false; + + const knownArgs = args as unknown as UserConfig & CliOptions; // the first position argument should be used // instead of --connectionString, as it's how the mongosh works. - if (args.connectionString) { + if (knownArgs.connectionString) { usedDeprecatedArgument = true; warn( "The --connectionString argument is deprecated. Prefer using the first positional argument for the connection string or the MDB_MCP_CONNECTION_STRING environment variable." ); } - if (usedDeprecatedArgument) { + for (const providedKey of Object.keys(args)) { + if (providedKey === "_") { + // positional argument + continue; + } + + const { valid, suggestion } = validateConfigKey(providedKey); + if (!valid) { + usedInvalidArgument = true; + if (suggestion) { + warn(`Invalid command line argument '${providedKey}'. Did you mean '${suggestion}'?`); + } else { + warn(`Invalid command line argument '${providedKey}'.`); + } + } + } + + if (usedInvalidArgument || usedDeprecatedArgument) { warn("Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."); } + + if (usedInvalidArgument) { + exit(1); + } } function commaSeparatedToArray(str: string | string[] | undefined): T { diff --git a/tests/unit/common/config.test.ts b/tests/unit/common/config.test.ts index f325461d..a497cb33 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -3,8 +3,8 @@ import type { UserConfig } from "../../../src/common/config.js"; import { setupUserConfig, defaultUserConfig, - warnAboutDeprecatedCliArgs, registerKnownSecretsInRootKeychain, + warnAboutDeprecatedOrUnknownCliArgs, } from "../../../src/common/config.js"; import type { CliOptions } from "@mongosh/arg-parser"; import { Keychain } from "../../../src/common/keychain.js"; @@ -638,7 +638,7 @@ describe("config", () => { }); }); -describe("Deprecated CLI arguments", () => { +describe("CLI arguments", () => { const referDocMessage = "Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."; @@ -655,12 +655,14 @@ describe("Deprecated CLI arguments", () => { describe(`deprecation behaviour of ${cliArg}`, () => { let cliArgs: CliOptions & UserConfig & { _?: string[] }; let warn: (msg: string) => void; + let exit: (status: number) => void | never; beforeEach(() => { cliArgs = { [cliArg]: "RandomString" } as unknown as CliOptions & UserConfig & { _?: string[] }; warn = vi.fn(); + exit = vi.fn(); - warnAboutDeprecatedCliArgs(cliArgs, warn); + warnAboutDeprecatedOrUnknownCliArgs(cliArgs as unknown as Record, { warn, exit }); }); it(`warns the usage of ${cliArg} as it is deprecated`, () => { @@ -670,9 +672,76 @@ describe("Deprecated CLI arguments", () => { it(`shows the reference message when ${cliArg} was passed`, () => { expect(warn).toHaveBeenCalledWith(referDocMessage); }); + + it(`should not exit the process`, () => { + expect(exit).not.toHaveBeenCalled(); + }); }); } + describe("invalid arguments", () => { + let warn: (msg: string) => void; + let exit: (status: number) => void | never; + + beforeEach(() => { + warn = vi.fn(); + exit = vi.fn(); + }); + + it("should show a warning when an argument is not known", () => { + warnAboutDeprecatedOrUnknownCliArgs( + { + wakanda: "", + }, + { warn, exit } + ); + + expect(warn).toHaveBeenCalledWith("Invalid command line argument 'wakanda'."); + expect(warn).toHaveBeenCalledWith( + "Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." + ); + }); + + it("should exit the process on unknown cli args", () => { + warnAboutDeprecatedOrUnknownCliArgs( + { + wakanda: "", + }, + { warn, exit } + ); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it("should show a suggestion when is a simple typo", () => { + warnAboutDeprecatedOrUnknownCliArgs( + { + readonli: "", + }, + { warn, exit } + ); + + expect(warn).toHaveBeenCalledWith("Invalid command line argument 'readonli'. Did you mean 'readOnly'?"); + expect(warn).toHaveBeenCalledWith( + "Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." + ); + }); + + it("should show a suggestion when the only change is on the case", () => { + warnAboutDeprecatedOrUnknownCliArgs( + { + readonly: "", + }, + { warn, exit } + ); + + expect(warn).toHaveBeenCalledWith("Invalid command line argument 'readonly'. Did you mean 'readOnly'?"); + expect(warn).toHaveBeenCalledWith( + "Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." + ); + }); + }); + describe("keychain management", () => { type TestCase = { readonly cliArg: keyof UserConfig; secretKind: Secret["kind"] }; const testCases = [ diff --git a/tsconfig.build.json b/tsconfig.build.json index aa40521b..06089861 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -21,7 +21,8 @@ "paths": { "mongodb-connection-string-url": [ "./node_modules/mongodb-connection-string-url/lib/index.d.ts" - ] + ], + "ts-levenshtein": ["./node_modules/ts-levenshtein/dist/index.d.mts"] } }, "include": ["src/**/*.ts"]