Skip to content

Commit 49a9628

Browse files
feat: resolve config defaults after merging CLI and config file
Parse CLI arguments and the JSON config file without yargs defaults so only explicit user intent is captured, merge them with CLI taking precedence, then apply defaults (including the --viaCli ones). Conflict checks now only consider explicitly provided arguments, so implicit defaults such as channel=stable can no longer conflict with explicit flags. Also restores --help/--version exiting via a shared buildCliParser and adds edge-case tests for config parsing, precedence and conflicts.
1 parent 4654be7 commit 49a9628

4 files changed

Lines changed: 391 additions & 122 deletions

File tree

‎src/config/browser-options.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const browserOptions = {
9191
type: 'boolean',
9292
description:
9393
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.',
94-
defaultDescription: 'false',
94+
default: false,
9595
},
9696
userDataDir: {
9797
type: 'string',
@@ -103,7 +103,7 @@ export const browserOptions = {
103103
description:
104104
'Specify a different Chrome channel that should be used. The default is the stable channel version.',
105105
choices: ['canary', 'dev', 'beta', 'stable'] as const,
106-
defaultDescription: 'stable',
106+
default: 'stable' as const,
107107
},
108108
proxyServer: {
109109
type: 'string',

‎src/config/mcp-options.ts‎

Lines changed: 147 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const mcpOptions = {
144144
describe:
145145
"Restricts browser's network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns. A pattern that uses a regexp group in any component (for example `(127\\.\\d+\\.\\d+\\.\\d+)` in the hostname) is rejected, because it is not enforced on redirects or subresources; use an exact value or a `*`/`:name` wildcard instead.",
146146
coerce: (arg: string[] | undefined) => {
147-
if (arg === undefined) {
147+
if (arg === undefined || arg.length === 0) {
148148
return undefined;
149149
}
150150
const pattern = findUnenforceablePattern(arg);
@@ -165,6 +165,11 @@ export const mcpOptions = {
165165
if (arg === undefined) {
166166
return undefined;
167167
}
168+
if (arg.length === 0) {
169+
throw new Error(
170+
'Invalid --allowedUrlPattern: at least one pattern is required.',
171+
);
172+
}
168173
const pattern = findUnenforceablePattern(arg);
169174
if (pattern) {
170175
throw new Error(
@@ -319,7 +324,9 @@ export const mcpOptions = {
319324
},
320325
} satisfies Record<string, YargsOptions>;
321326

322-
export type ParsedArguments = ReturnType<typeof parseArguments>;
327+
export type ParsedArguments = ReturnType<
328+
ReturnType<typeof parser>['parseSync']
329+
>;
323330

324331
export function getMcpOptionsForViaCli(): typeof mcpOptions {
325332
if (!('default' in mcpOptions.headless)) {
@@ -466,140 +473,165 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
466473
return typeof value === 'object' && value !== null && !Array.isArray(value);
467474
}
468475

476+
function isParsedArguments(value: unknown): value is ParsedArguments {
477+
return isPlainObject(value) && Array.isArray(value['_']);
478+
}
479+
469480
function getErrorMessage(err: unknown): string {
470481
return err instanceof Error ? err.message : String(err);
471482
}
472483

473-
/**
474-
* Exported only for testing to not trigger process exit.
475-
*/
476-
export function parser(
484+
function buildCliParser<T extends Record<string, YargsOptions>>(
477485
version: string,
478-
argv = process.argv,
479-
env = process.env,
486+
argv: string[],
487+
options: T,
480488
) {
481-
const isViaCli = argv.includes('--viaCli') || argv.includes('--via-cli');
482-
const options = isViaCli ? getMcpOptionsForViaCli() : mcpOptions;
483-
484-
const yargsInstance = yargs(hideBin(argv))
489+
const yargsInstance = yargs(hideBin(argv));
490+
return yargsInstance
485491
.scriptName('npx chrome-devtools-mcp@latest')
486492
.parserConfiguration({
487493
'strip-aliased': true,
488494
'strip-dashed': true,
489495
})
490496
.options(options)
491497
.showHelpOnFail(false, 'Specify --help for available options')
492-
.check(args => {
493-
const activeArgs = new Set<string>();
494-
495-
for (const [key, val] of Object.entries(args)) {
496-
if (val !== undefined && val !== false) {
497-
activeArgs.add(key);
498-
}
499-
}
500-
501-
for (const group of CONFLICTING_ARGS) {
502-
// Find all active arguments within this conflict group
503-
const activeInGroup = group.filter(arg => activeArgs.has(arg));
504-
505-
if (activeInGroup.length > 1) {
506-
const [arg1, arg2] = activeInGroup;
507-
throw new Error(
508-
`Arguments ${arg1} and ${arg2} are mutually exclusive`,
509-
);
510-
}
511-
}
512-
513-
return true;
514-
})
515-
.middleware(args => {
516-
args.channel = args.channel ?? 'stable';
517-
if (isViaCli) {
518-
if (args.filesystemRoot === DEFAULT_FILESYSTEM_ROOT) {
519-
const cliFilesystemArgs: {
520-
allowUnrestrictedPaths?: boolean;
521-
filesystemRoot?: unknown;
522-
} = args;
523-
cliFilesystemArgs.allowUnrestrictedPaths = true;
524-
cliFilesystemArgs.filesystemRoot = undefined;
525-
}
526-
// Defaults that cannot be set in options without affecting yargs conflict resolution.
527-
if (
528-
args.isolated === undefined &&
529-
args.userDataDir === undefined &&
530-
!args.autoConnect &&
531-
!args.browserUrl &&
532-
!args.wsEndpoint
533-
) {
534-
args.isolated = true;
535-
}
536-
}
537-
args.isolated = args.isolated ?? false;
538-
if (
539-
args.experimentalToonFormat &&
540-
args.experimentalDataFormat === 'default'
541-
) {
542-
args.experimentalDataFormat = 'toon';
543-
}
544-
if (env['CI'] || env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
545-
console.error(
546-
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
547-
);
548-
args.usageStatistics = false;
549-
}
550-
551-
const cliOptionsAllowedArgs = [
552-
...Object.keys(options),
553-
// Yargs populated with positional args
554-
'_',
555-
'$0',
556-
];
557-
558-
const unknownArgs = Object.keys(args).filter(
559-
arg => !cliOptionsAllowedArgs.includes(arg),
560-
);
561-
562-
if (unknownArgs.length > 0) {
563-
console.error(
564-
`Unknown arguments: ${unknownArgs.map(arg => `--${arg}`)}`,
565-
);
566-
}
567-
})
568-
.example(CLI_EXAMPLES);
569-
570-
return yargsInstance
571-
.config('config', 'Path to JSON configuration file', configPath => {
572-
try {
573-
const parsed: unknown = JSON.parse(readFileSync(configPath, 'utf-8'));
574-
if (!isPlainObject(parsed)) {
575-
throw new Error('Config must be a JSON object');
576-
}
577-
578-
yargs()
579-
.parserConfiguration({
580-
'strip-aliased': true,
581-
'camel-case-expansion': false,
582-
})
583-
.options(options)
584-
.config(parsed)
585-
.strict()
586-
.fail(false)
587-
.exitProcess(false)
588-
.parseSync([]);
589-
return parsed;
590-
} catch (err) {
591-
throw new Error(`Invalid JSON config file: ${getErrorMessage(err)}`);
592-
}
593-
})
498+
.example(CLI_EXAMPLES)
594499
.wrap(Math.min(120, yargsInstance.terminalWidth()))
595500
.help()
596501
.version(version);
597502
}
598503

504+
export function parser(version: string, argv = process.argv) {
505+
// Used to derive the ParsedArguments type.
506+
return buildCliParser(version, argv, mcpOptions);
507+
}
508+
599509
export function parseArguments(
600510
version: string,
601511
argv = process.argv,
602512
env = process.env,
603513
) {
604-
return parser(version, argv, env).parseSync();
514+
// Step 1 & 2: Independent Parsing (Bypassing yargs Defaults)
515+
const optionsWithoutDefaults: Record<string, YargsOptions> = {};
516+
for (const [key, option] of Object.entries(mcpOptions)) {
517+
const copy: YargsOptions = {...option};
518+
if (copy.default !== undefined) {
519+
copy.defaultDescription ??= JSON.stringify(copy.default);
520+
delete copy.default;
521+
}
522+
optionsWithoutDefaults[key] = copy;
523+
}
524+
525+
// `--help` and `--version` print and exit the process; parse errors throw.
526+
const yargsInstance = buildCliParser(
527+
version,
528+
argv,
529+
optionsWithoutDefaults,
530+
).fail(false);
531+
532+
const rawCli: Record<string, unknown> = yargsInstance.parseSync();
533+
534+
// Config file parsing
535+
let parsedConfigFile: Record<string, unknown> = {};
536+
if (typeof rawCli.config === 'string') {
537+
try {
538+
const fileContent: unknown = JSON.parse(
539+
readFileSync(rawCli.config, 'utf-8'),
540+
);
541+
if (!isPlainObject(fileContent)) {
542+
throw new Error('Config must be a JSON object');
543+
}
544+
545+
// Run the config through yargs to reject unknown keys and apply
546+
// coercions, but without defaults.
547+
parsedConfigFile = yargs([])
548+
.parserConfiguration({
549+
'strip-aliased': true,
550+
'camel-case-expansion': false,
551+
})
552+
.options(optionsWithoutDefaults)
553+
.config(fileContent)
554+
.strict()
555+
.fail(false)
556+
.exitProcess(false)
557+
.parseSync([]);
558+
} catch (err) {
559+
throw new Error(`Invalid JSON config file: ${getErrorMessage(err)}`);
560+
}
561+
}
562+
563+
// Step 3: Cascade Merge of Explicit Inputs
564+
const explicitConfig = {...parsedConfigFile, ...rawCli};
565+
566+
// Step 4: Dynamic Default Resolution. `viaCli` is an explicit input like any
567+
// other; it selects which defaults apply.
568+
const isViaCli = explicitConfig.viaCli === true;
569+
const baseOptions = isViaCli ? getMcpOptionsForViaCli() : mcpOptions;
570+
const resolvedConfig = {...explicitConfig};
571+
572+
for (const [key, option] of Object.entries(baseOptions)) {
573+
if (resolvedConfig[key] === undefined && 'default' in option) {
574+
resolvedConfig[key] = option.default;
575+
}
576+
}
577+
578+
if (isViaCli) {
579+
if (resolvedConfig.filesystemRoot === DEFAULT_FILESYSTEM_ROOT) {
580+
resolvedConfig.allowUnrestrictedPaths = true;
581+
resolvedConfig.filesystemRoot = undefined;
582+
}
583+
if (
584+
explicitConfig.isolated === undefined &&
585+
resolvedConfig.userDataDir === undefined &&
586+
!resolvedConfig.autoConnect &&
587+
!resolvedConfig.browserUrl &&
588+
!resolvedConfig.wsEndpoint
589+
) {
590+
resolvedConfig.isolated = true;
591+
}
592+
}
593+
594+
if (
595+
resolvedConfig.experimentalToonFormat &&
596+
resolvedConfig.experimentalDataFormat === 'default'
597+
) {
598+
resolvedConfig.experimentalDataFormat = 'toon';
599+
}
600+
if (env['CI'] || env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
601+
console.error(
602+
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
603+
);
604+
resolvedConfig.usageStatistics = false;
605+
}
606+
607+
const cliOptionsAllowedArgs = [...Object.keys(baseOptions), '_', '$0'];
608+
609+
const unknownArgs = Object.keys(rawCli).filter(
610+
arg => !cliOptionsAllowedArgs.includes(arg),
611+
);
612+
613+
if (unknownArgs.length > 0) {
614+
console.error(`Unknown arguments: ${unknownArgs.map(arg => `--${arg}`)}`);
615+
}
616+
617+
// Step 5: Validation. Conflicts are only checked against explicit user input
618+
// so that dynamic defaults never conflict.
619+
const activeArgs = new Set<string>();
620+
for (const [key, val] of Object.entries(explicitConfig)) {
621+
if (val !== undefined && val !== false) {
622+
activeArgs.add(key);
623+
}
624+
}
625+
for (const group of CONFLICTING_ARGS) {
626+
const activeInGroup = group.filter(arg => activeArgs.has(arg));
627+
if (activeInGroup.length > 1) {
628+
const [arg1, arg2] = activeInGroup;
629+
throw new Error(`Arguments ${arg1} and ${arg2} are mutually exclusive`);
630+
}
631+
}
632+
633+
if (!isParsedArguments(resolvedConfig)) {
634+
throw new Error('Failed to resolve configuration');
635+
}
636+
return resolvedConfig;
605637
}

0 commit comments

Comments
 (0)