Skip to content

Commit 0912f39

Browse files
kmruizCopilot
andauthored
feat(cli): notify when a flag is wrong and suggest a fix MCP-121 (#517)
Co-authored-by: Copilot <[email protected]>
1 parent 1f68c3e commit 0912f39

File tree

5 files changed

+163
-19
lines changed

5 files changed

+163
-19
lines changed

package-lock.json

Lines changed: 13 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"node-machine-id": "1.1.12",
115115
"oauth4webapi": "^3.8.0",
116116
"openapi-fetch": "^0.14.0",
117+
"ts-levenshtein": "^1.0.7",
117118
"yargs-parser": "^22.0.0",
118119
"zod": "^3.25.76"
119120
},

src/common/config.ts

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CliOptions, ConnectionInfo } from "@mongosh/arg-parser";
55
import { generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser";
66
import { Keychain } from "./keychain.js";
77
import type { Secret } from "./keychain.js";
8+
import levenshtein from "ts-levenshtein";
89

910
// From: https://github.com/mongodb-js/mongosh/blob/main/packages/cli-repl/src/arg-parser.ts
1011
const OPTIONS = {
@@ -91,6 +92,44 @@ const OPTIONS = {
9192
},
9293
} as const;
9394

95+
const ALL_CONFIG_KEYS = new Set(
96+
(OPTIONS.string as readonly string[])
97+
.concat(OPTIONS.array)
98+
.concat(OPTIONS.boolean)
99+
.concat(Object.keys(OPTIONS.alias))
100+
);
101+
102+
export function validateConfigKey(key: string): { valid: boolean; suggestion?: string } {
103+
if (ALL_CONFIG_KEYS.has(key)) {
104+
return { valid: true };
105+
}
106+
107+
let minLev = Number.MAX_VALUE;
108+
let suggestion = "";
109+
110+
// find the closest match for a suggestion
111+
for (const validKey of ALL_CONFIG_KEYS) {
112+
// check if there is an exact case-insensitive match
113+
if (validKey.toLowerCase() === key.toLowerCase()) {
114+
return { valid: false, suggestion: validKey };
115+
}
116+
117+
// else, infer something using levenshtein so we suggest a valid key
118+
const lev = levenshtein.get(key, validKey);
119+
if (lev < minLev) {
120+
minLev = lev;
121+
suggestion = validKey;
122+
}
123+
}
124+
125+
if (minLev <= 2) {
126+
// accept up to 2 typos
127+
return { valid: false, suggestion };
128+
}
129+
130+
return { valid: false };
131+
}
132+
94133
function isConnectionSpecifier(arg: string | undefined): boolean {
95134
return (
96135
arg !== undefined &&
@@ -267,7 +306,13 @@ function parseCliConfig(args: string[]): CliOptions {
267306
// so we don't have a logger. For stdio, the warning will be received as a string in
268307
// the client and IDEs like VSCode do show the message in the log window. For HTTP,
269308
// it will be in the stdout of the process.
270-
warnAboutDeprecatedCliArgs({ ...parsed, _: positionalArguments }, console.warn);
309+
warnAboutDeprecatedOrUnknownCliArgs(
310+
{ ...parsed, _: positionalArguments },
311+
{
312+
warn: (msg) => console.warn(msg),
313+
exit: (status) => process.exit(status),
314+
}
315+
);
271316

272317
// if we have a positional argument that matches a connection string
273318
// store it as the connection specifier and remove it from the argument
@@ -280,26 +325,47 @@ function parseCliConfig(args: string[]): CliOptions {
280325
return parsed;
281326
}
282327

283-
export function warnAboutDeprecatedCliArgs(
284-
args: CliOptions &
285-
UserConfig & {
286-
_?: string[];
287-
},
288-
warn: (msg: string) => void
328+
export function warnAboutDeprecatedOrUnknownCliArgs(
329+
args: Record<string, unknown>,
330+
{ warn, exit }: { warn: (msg: string) => void; exit: (status: number) => void | never }
289331
): void {
290332
let usedDeprecatedArgument = false;
333+
let usedInvalidArgument = false;
334+
335+
const knownArgs = args as unknown as UserConfig & CliOptions;
291336
// the first position argument should be used
292337
// instead of --connectionString, as it's how the mongosh works.
293-
if (args.connectionString) {
338+
if (knownArgs.connectionString) {
294339
usedDeprecatedArgument = true;
295340
warn(
296341
"The --connectionString argument is deprecated. Prefer using the first positional argument for the connection string or the MDB_MCP_CONNECTION_STRING environment variable."
297342
);
298343
}
299344

300-
if (usedDeprecatedArgument) {
345+
for (const providedKey of Object.keys(args)) {
346+
if (providedKey === "_") {
347+
// positional argument
348+
continue;
349+
}
350+
351+
const { valid, suggestion } = validateConfigKey(providedKey);
352+
if (!valid) {
353+
usedInvalidArgument = true;
354+
if (suggestion) {
355+
warn(`Invalid command line argument '${providedKey}'. Did you mean '${suggestion}'?`);
356+
} else {
357+
warn(`Invalid command line argument '${providedKey}'.`);
358+
}
359+
}
360+
}
361+
362+
if (usedInvalidArgument || usedDeprecatedArgument) {
301363
warn("Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server.");
302364
}
365+
366+
if (usedInvalidArgument) {
367+
exit(1);
368+
}
303369
}
304370

305371
function commaSeparatedToArray<T extends string[]>(str: string | string[] | undefined): T {

tests/unit/common/config.test.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { UserConfig } from "../../../src/common/config.js";
33
import {
44
setupUserConfig,
55
defaultUserConfig,
6-
warnAboutDeprecatedCliArgs,
76
registerKnownSecretsInRootKeychain,
7+
warnAboutDeprecatedOrUnknownCliArgs,
88
} from "../../../src/common/config.js";
99
import type { CliOptions } from "@mongosh/arg-parser";
1010
import { Keychain } from "../../../src/common/keychain.js";
@@ -638,7 +638,7 @@ describe("config", () => {
638638
});
639639
});
640640

641-
describe("Deprecated CLI arguments", () => {
641+
describe("CLI arguments", () => {
642642
const referDocMessage =
643643
"Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server.";
644644

@@ -655,12 +655,14 @@ describe("Deprecated CLI arguments", () => {
655655
describe(`deprecation behaviour of ${cliArg}`, () => {
656656
let cliArgs: CliOptions & UserConfig & { _?: string[] };
657657
let warn: (msg: string) => void;
658+
let exit: (status: number) => void | never;
658659

659660
beforeEach(() => {
660661
cliArgs = { [cliArg]: "RandomString" } as unknown as CliOptions & UserConfig & { _?: string[] };
661662
warn = vi.fn();
663+
exit = vi.fn();
662664

663-
warnAboutDeprecatedCliArgs(cliArgs, warn);
665+
warnAboutDeprecatedOrUnknownCliArgs(cliArgs as unknown as Record<string, unknown>, { warn, exit });
664666
});
665667

666668
it(`warns the usage of ${cliArg} as it is deprecated`, () => {
@@ -670,9 +672,76 @@ describe("Deprecated CLI arguments", () => {
670672
it(`shows the reference message when ${cliArg} was passed`, () => {
671673
expect(warn).toHaveBeenCalledWith(referDocMessage);
672674
});
675+
676+
it(`should not exit the process`, () => {
677+
expect(exit).not.toHaveBeenCalled();
678+
});
673679
});
674680
}
675681

682+
describe("invalid arguments", () => {
683+
let warn: (msg: string) => void;
684+
let exit: (status: number) => void | never;
685+
686+
beforeEach(() => {
687+
warn = vi.fn();
688+
exit = vi.fn();
689+
});
690+
691+
it("should show a warning when an argument is not known", () => {
692+
warnAboutDeprecatedOrUnknownCliArgs(
693+
{
694+
wakanda: "",
695+
},
696+
{ warn, exit }
697+
);
698+
699+
expect(warn).toHaveBeenCalledWith("Invalid command line argument 'wakanda'.");
700+
expect(warn).toHaveBeenCalledWith(
701+
"Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."
702+
);
703+
});
704+
705+
it("should exit the process on unknown cli args", () => {
706+
warnAboutDeprecatedOrUnknownCliArgs(
707+
{
708+
wakanda: "",
709+
},
710+
{ warn, exit }
711+
);
712+
713+
expect(exit).toHaveBeenCalledWith(1);
714+
});
715+
716+
it("should show a suggestion when is a simple typo", () => {
717+
warnAboutDeprecatedOrUnknownCliArgs(
718+
{
719+
readonli: "",
720+
},
721+
{ warn, exit }
722+
);
723+
724+
expect(warn).toHaveBeenCalledWith("Invalid command line argument 'readonli'. Did you mean 'readOnly'?");
725+
expect(warn).toHaveBeenCalledWith(
726+
"Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."
727+
);
728+
});
729+
730+
it("should show a suggestion when the only change is on the case", () => {
731+
warnAboutDeprecatedOrUnknownCliArgs(
732+
{
733+
readonly: "",
734+
},
735+
{ warn, exit }
736+
);
737+
738+
expect(warn).toHaveBeenCalledWith("Invalid command line argument 'readonly'. Did you mean 'readOnly'?");
739+
expect(warn).toHaveBeenCalledWith(
740+
"Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."
741+
);
742+
});
743+
});
744+
676745
describe("keychain management", () => {
677746
type TestCase = { readonly cliArg: keyof UserConfig; secretKind: Secret["kind"] };
678747
const testCases = [

tsconfig.build.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"paths": {
2222
"mongodb-connection-string-url": [
2323
"./node_modules/mongodb-connection-string-url/lib/index.d.ts"
24-
]
24+
],
25+
"ts-levenshtein": ["./node_modules/ts-levenshtein/dist/index.d.mts"]
2526
}
2627
},
2728
"include": ["src/**/*.ts"]

0 commit comments

Comments
 (0)