feat(config): introduce layered ServerConfig for live reload (#3203)

Introduces a layered `ServerConfig` class to hold explicit CLI
arguments, config file arguments, and resolved options separately during
live config reloads. Most of the validation and default-resolution logic
moved directly out of `ConfigParser.ts` into `ServerConfig.ts`.

### Moved from `ConfigParser.ts` to `ServerConfig.ts`
- `ParsedArguments` type (re-exported from `ConfigParser.ts`; `channel`
and `filesystemRoot` are now typed as optional to match runtime behavior
without `as` casts).
- `isMcpOptionKey` and `copyOption` helpers (moved unchanged).
- `validateConflicts` and `validateImplications` (moved unchanged from
`ConfigParser` static methods to `ServerConfig` static methods).
- `applyDefaults` (`viaCli`, `channel`, `filesystemRoot`, and `CI` /
usage-statistics rules are unchanged; defaults are now parsed via
`yargs([]).options(baseOptions).parseSync([])` instead of an untyped
loop with `as unknown as ParsedArguments`).
- Reloadable option copy loop from `ConfigParser.mergeReloadableOptions`
(moved into `mergeLayer` in `ServerConfig.ts`).

### New / changed behavior
- **`applyDefaults()` drops unknown keys (`filterExplicitArgs`)**: Keys
that are not in `mcpOptions` are now dropped when resolving arguments.
Before this change, unknown CLI arguments stayed on the resolved
`ParsedArguments` object returned by `ConfigParser.parse()` /
`ConfigParser.reload()` after `ConfigParser.warnUnknownArgs()` logged
the warning. Consumers that iterate over `mcpOptions` keys (such as
telemetry `computeFlagUsage()`) do not change output, while callers
inspecting `Object.keys(resolved)` no longer observe unknown CLI keys.
- **Layered `ServerConfig`**: Stores `cliArgs`, `fileArgs`,
`configPath`, `env`, and `resolved` separately (replacing the
module-level `explicitArgsByParsed` `WeakMap`), so reloading a config
file updates reloadable file options while preserving startup CLI flags
and non-reloadable startup file options.
- **`ReadonlyServerConfig`, `mergeReloadable(next, options)`, and
`commit(next)`**: Components read `.resolved` through
`ReadonlyServerConfig`, while the server computes a candidate reload via
`mergeReloadable(next, options)` and commits it in place via
`commit(next)`. `ServerConfig.prototype.mergeReloadable()` and
`ServerConfig.prototype.commit()` have no production caller in this PR
(wired up in subsequent PRs in this stack).
- **`ServerConfig.create`**: Factory method to construct `ServerConfig`
from raw CLI and config-file layers.
- **`ConfigParser.parseConfig()` and `reloadConfig()`**: Return
`ServerConfig` while keeping `parse()` and `reload()` returning
`ParsedArguments` for compatibility
(`ConfigParser.prototype.reloadConfig()` is wired up in subsequent PRs
in this stack).
- **`mcpOptionsForViaCli` and `stripYargsPositionalArgs`
(`src/config/mcp-options.ts`)**: `mcpOptionsForViaCli` is exported
directly as a `satisfies` constant (replacing
`getMcpOptionsForViaCli()`), and `stripYargsPositionalArgs` is shared
between `ConfigParser.ts` and `ServerConfig.ts`.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-19 12:24:05 +00:00
1 parent 0f95c21b02
commit 37f1fda81b
6 files changed
+760 -463

No files matched your search

+42 -202
View File
@@ -4,62 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {Argv} from 'yargs';
import type {YargsOptions, InferredOptionTypes} from '../third_party/cli.js';
import {yargs, hideBin} from '../third_party/cli.js';
import {readFileSync} from 'node:fs';
import type {Argv} from 'yargs';
import type {YargsOptions, InferredOptionTypes} from '../third_party/cli.js';
import {yargs, hideBin} from '../third_party/cli.js';
import {getErrorMessage} from '../utils/errorHandling.js';
import {ConfigLocator} from './ConfigLocator.js';
import {
mcpOptions,
getMcpOptionsForViaCli,
CLI_EXAMPLES,
CONFLICTING_ARGS,
IMPLICATIONS,
DEFAULT_FILESYSTEM_ROOT,
isReloadableOption,
stripYargsPositionalArgs,
withoutDefaults,
} from './mcp-options.js';
import {getErrorMessage} from '../utils/errorHandling.js';
import {ConfigLocator} from './ConfigLocator.js';
import type {ParsedArguments} from './ServerConfig.js';
import {ServerConfig} from './ServerConfig.js';
export type {ParsedArguments} from './ServerConfig.js';
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripYargsPositionalArgs<T extends {_?: unknown; $0?: unknown}>(
parsed: T,
): Omit<T, '_' | '$0'> {
const copy = {...parsed};
delete copy._;
delete copy.$0;
return copy;
}
export type ParsedArguments = InferredOptionTypes<typeof mcpOptions>;
function isMcpOptionKey(key: string): key is keyof ParsedArguments {
return key in mcpOptions;
}
function copyOption<K extends keyof ParsedArguments>(
target: Partial<ParsedArguments>,
source: Partial<ParsedArguments>,
key: K,
): void {
if (key in source) {
target[key] = source[key];
} else {
delete target[key];
}
}
const explicitArgsByParsed = new WeakMap<
ParsedArguments,
{parser: ConfigParser; explicitArgs: Partial<ParsedArguments>}
>();
export class ConfigParser {
#configPath?: string;
#usageStatisticsNoticeShown = false;
@@ -152,130 +120,15 @@ export class ConfigParser {
}
/**
* Step 4: rejects mutually exclusive inputs. Only explicit inputs are checked,
* so defaults never conflict.
* Parses CLI arguments and the config file into a layered
* {@link ServerConfig}.
*/
static validateConflicts(explicitArgs: Partial<ParsedArguments>): void {
const activeArgs = new Set<string>();
for (const [key, val] of Object.entries(explicitArgs)) {
if (val !== undefined && val !== false) {
activeArgs.add(key);
}
}
for (const group of CONFLICTING_ARGS) {
const activeInGroup = group.filter(arg => activeArgs.has(arg));
if (activeInGroup.length > 1) {
const [arg1, arg2] = activeInGroup;
throw new Error(
`Arguments ${String(arg1)} and ${String(arg2)} are mutually exclusive`,
);
}
}
}
static validateImplications(explicitArgs: Partial<ParsedArguments>): void {
for (const [key, implied] of IMPLICATIONS) {
const isKeySet =
explicitArgs[key] !== undefined && explicitArgs[key] !== false;
const isImpliedSet =
explicitArgs[implied] !== undefined && explicitArgs[implied] !== false;
if (isKeySet && !isImpliedSet) {
throw new Error(
`Implications failed:\n ${String(key)} -> ${String(implied)}`,
);
}
}
}
/**
* Step 5: fills in defaults for everything that was not set explicitly.
* `viaCli` is an explicit input like any other; it selects which defaults
* apply.
*/
applyDefaults(explicitArgs: Partial<ParsedArguments>): ParsedArguments {
const isViaCli = explicitArgs.viaCli === true;
const baseOptions = isViaCli ? getMcpOptionsForViaCli() : mcpOptions;
const resolvedArgs = {...explicitArgs};
// `channel` only applies when Chrome is launched by channel. Leaving it
// unset otherwise keeps it out of telemetry (computeFlagUsage).
const launchesByChannel =
!resolvedArgs.browserUrl &&
!resolvedArgs.wsEndpoint &&
!resolvedArgs.executablePath;
for (const [key, option] of Object.entries(baseOptions)) {
if (key === 'channel' && !launchesByChannel) {
continue;
}
if (
resolvedArgs[key as keyof ParsedArguments] === undefined &&
'default' in option
) {
resolvedArgs[key as keyof ParsedArguments] = option.default;
}
}
if (isViaCli) {
if (
resolvedArgs.filesystemRoot === DEFAULT_FILESYSTEM_ROOT &&
explicitArgs.allowUnrestrictedPaths !== false
) {
resolvedArgs.allowUnrestrictedPaths = true;
resolvedArgs.filesystemRoot = undefined;
}
const connectsToExistingBrowser =
resolvedArgs.autoConnect ||
resolvedArgs.browserUrl ||
resolvedArgs.wsEndpoint;
if (
explicitArgs.isolated === undefined &&
resolvedArgs.userDataDir === undefined &&
!connectsToExistingBrowser
) {
resolvedArgs.isolated = true;
}
if (
resolvedArgs.categoryExtensions === undefined &&
!connectsToExistingBrowser
) {
resolvedArgs.categoryExtensions = true;
}
}
if (this.env['CI'] || this.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
if (!this.#usageStatisticsNoticeShown) {
this.#usageStatisticsNoticeShown = true;
console.error(
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
);
}
resolvedArgs.usageStatistics = false;
}
if (
resolvedArgs.categoryExperimentalThirdParty === undefined &&
this.env['CHROME_DEVTOOLS_MCP_CATEGORY_EXPERIMENTAL_THIRD_PARTY']
) {
resolvedArgs.categoryExperimentalThirdParty = true;
}
// The merge and the default loop lose the static type that yargs infers from
// `mcpOptions`. Every value was produced by the same option definitions (CLI
// parser, strict config-file parser, option defaults), so the shape matches.
const parsed: ParsedArguments = resolvedArgs as unknown as ParsedArguments;
explicitArgsByParsed.set(parsed, {
parser: this,
explicitArgs: {...explicitArgs},
});
return parsed;
}
parse(): ParsedArguments {
parseConfig(): ServerConfig {
try {
const cliArgs = this.parseCliArgs();
this.#configPath = cliArgs.config ?? this.configLocator.locate(this.env);
this.warnUnknownArgs(cliArgs);
return this.#resolve(cliArgs);
return this.#resolveConfig(cliArgs);
} catch (error) {
if (this.exitProcess) {
console.error(getErrorMessage(error));
@@ -285,53 +138,40 @@ export class ConfigParser {
}
}
parse(): ParsedArguments {
return this.parseConfig().resolved;
}
/**
* Re-reads the config file resolved by `parse()` and merges it with the CLI
* arguments again into a new {@link ServerConfig}. Unlike `parseConfig()`, it
* does not discover a new config file and throws on invalid configuration
* instead of exiting the process.
*/
reloadConfig(): ServerConfig {
return this.#resolveConfig(this.parseCliArgs());
}
/**
* Re-reads the config file resolved by `parse()` and merges it with the CLI
* arguments again. Unlike `parse()`, it does not discover a new config file
* and throws on invalid configuration instead of exiting the process.
*/
reload(): ParsedArguments {
return this.#resolve(this.parseCliArgs());
return this.reloadConfig().resolved;
}
#resolve(cliArgs: Partial<ParsedArguments>): ParsedArguments {
#resolveConfig(cliArgs: Partial<ParsedArguments>): ServerConfig {
const configPath = this.#configPath;
const configFileArgs = configPath ? this.parseConfigFile(configPath) : {};
// Step 3: merges the explicit inputs. The CLI wins over the config file.
const explicitArgs: Partial<ParsedArguments> = {
...configFileArgs,
...cliArgs,
...(configPath ? {config: configPath} : {}),
};
ConfigParser.validateConflicts(explicitArgs);
ConfigParser.validateImplications(explicitArgs);
return this.applyDefaults(explicitArgs);
}
/**
* Merges explicit inputs so that a running server continues with `next` for
* reloadable options and `previous` for everything else, then validates and
* applies defaults.
*/
static mergeReloadableOptions(
previous: ParsedArguments,
next: ParsedArguments,
): ParsedArguments {
const previousEntry = explicitArgsByParsed.get(previous);
const nextEntry = explicitArgsByParsed.get(next);
const previousExplicit: Partial<ParsedArguments> =
previousEntry?.explicitArgs ?? previous;
const nextExplicit: Partial<ParsedArguments> =
nextEntry?.explicitArgs ?? next;
const merged: Partial<ParsedArguments> = {...previousExplicit};
for (const name of Object.keys(mcpOptions)) {
if (isMcpOptionKey(name) && isReloadableOption(name)) {
copyOption(merged, nextExplicit, name);
}
}
ConfigParser.validateConflicts(merged);
ConfigParser.validateImplications(merged);
const parser =
nextEntry?.parser ?? previousEntry?.parser ?? new ConfigParser('');
return parser.applyDefaults(merged);
const fileArgs = configPath ? this.parseConfigFile(configPath) : {};
const config = ServerConfig.create({
cliArgs,
fileArgs,
configPath,
env: this.env,
suppressUsageStatisticsNotice: this.#usageStatisticsNoticeShown,
});
this.#usageStatisticsNoticeShown = true;
return config;
}
}
+318
View File
@@ -0,0 +1,318 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {InferredOptionTypes} from '../third_party/cli.js';
import {yargs} from '../third_party/cli.js';
import {
CONFLICTING_ARGS,
DEFAULT_FILESYSTEM_ROOT,
IMPLICATIONS,
isReloadableOption,
mcpOptions,
mcpOptionsForViaCli,
stripYargsPositionalArgs,
} from './mcp-options.js';
type InferredMcpArguments = InferredOptionTypes<typeof mcpOptions>;
export type ParsedArguments = Omit<
InferredMcpArguments,
'channel' | 'filesystemRoot'
> & {
channel?: InferredMcpArguments['channel'];
filesystemRoot?: InferredMcpArguments['filesystemRoot'];
};
function isMcpOptionKey(key: string): key is keyof ParsedArguments {
return key in mcpOptions;
}
function copyOption<K extends keyof ParsedArguments>(
target: Partial<ParsedArguments>,
source: Partial<ParsedArguments>,
key: K,
): void {
if (key in source) {
target[key] = source[key];
} else {
delete target[key];
}
}
function filterExplicitArgs(
args: Partial<ParsedArguments>,
baseOptions: typeof mcpOptions | typeof mcpOptionsForViaCli,
): Partial<ParsedArguments> {
const filtered: Partial<ParsedArguments> = {};
for (const key of Object.keys(args)) {
if (!isMcpOptionKey(key)) {
continue;
}
if (args[key] === undefined && 'default' in baseOptions[key]) {
continue;
}
copyOption(filtered, args, key);
}
return filtered;
}
function mergeLayer(
previous: Readonly<Partial<ParsedArguments>>,
next: Readonly<Partial<ParsedArguments>>,
): Partial<ParsedArguments> {
const merged: Partial<ParsedArguments> = {...previous};
for (const name of Object.keys(mcpOptions)) {
if (isMcpOptionKey(name) && isReloadableOption(name)) {
copyOption(merged, next, name);
}
}
return merged;
}
function parseDefaultArgs(
options: typeof mcpOptions | typeof mcpOptionsForViaCli,
): ParsedArguments {
const parsed = yargs([])
.parserConfiguration({
'strip-aliased': true,
'camel-case-expansion': false,
})
.options(options)
.parseSync([]);
return {
...stripYargsPositionalArgs(parsed),
// yargs copies array defaults, so restore the shared reference that the
// `viaCli` check in `applyDefaults()` compares with `===`.
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
};
}
export interface ReadonlyServerConfig {
readonly resolved: Readonly<ParsedArguments>;
}
export interface ServerConfigInit {
cliArgs: Partial<ParsedArguments>;
fileArgs: Partial<ParsedArguments>;
configPath?: string;
env?: NodeJS.ProcessEnv;
resolved: ParsedArguments;
}
export interface ServerConfigCreateOptions {
cliArgs: Partial<ParsedArguments>;
fileArgs: Partial<ParsedArguments>;
configPath?: string;
env?: NodeJS.ProcessEnv;
suppressUsageStatisticsNotice?: boolean;
}
/**
* Holds explicit CLI and config-file layers alongside the resolved arguments.
*
* Components read live configuration through {@link ReadonlyServerConfig}.
* Only the owning server updates the config in place via {@link commit} after
* computing and validating a candidate reload via {@link mergeReloadable}.
*/
export class ServerConfig implements ReadonlyServerConfig {
#cliArgs: Readonly<Partial<ParsedArguments>>;
#fileArgs: Readonly<Partial<ParsedArguments>>;
readonly #configPath?: string;
readonly #env: NodeJS.ProcessEnv;
#resolved: Readonly<ParsedArguments>;
constructor(init: ServerConfigInit) {
this.#cliArgs = {...init.cliArgs};
this.#fileArgs = {...init.fileArgs};
this.#configPath = init.configPath;
this.#env = init.env ?? process.env;
this.#resolved = {...init.resolved};
}
/**
* Step 4: rejects mutually exclusive inputs. Only explicit inputs are
* checked, so defaults never conflict.
*/
static validateConflicts(explicitArgs: Partial<ParsedArguments>): void {
const activeArgs = new Set<string>();
for (const [key, val] of Object.entries(explicitArgs)) {
if (val !== undefined && val !== false) {
activeArgs.add(key);
}
}
for (const group of CONFLICTING_ARGS) {
const activeInGroup = group.filter(arg => activeArgs.has(arg));
if (activeInGroup.length > 1) {
const [arg1, arg2] = activeInGroup;
throw new Error(
`Arguments ${String(arg1)} and ${String(arg2)} are mutually exclusive`,
);
}
}
}
static validateImplications(explicitArgs: Partial<ParsedArguments>): void {
for (const [key, implied] of IMPLICATIONS) {
const isKeySet =
explicitArgs[key] !== undefined && explicitArgs[key] !== false;
const isImpliedSet =
explicitArgs[implied] !== undefined && explicitArgs[implied] !== false;
if (isKeySet && !isImpliedSet) {
throw new Error(
`Implications failed:\n ${String(key)} -> ${String(implied)}`,
);
}
}
}
/**
* Step 5: fills in defaults for everything that was not set explicitly.
* `viaCli` is an explicit input like any other; it selects which defaults
* apply.
*/
static applyDefaults(
explicitArgs: Partial<ParsedArguments>,
options?: {
env?: NodeJS.ProcessEnv;
suppressUsageStatisticsNotice?: boolean;
},
): ParsedArguments {
const env = options?.env ?? process.env;
const isViaCli = explicitArgs.viaCli === true;
const baseOptions = isViaCli ? mcpOptionsForViaCli : mcpOptions;
const resolvedArgs: ParsedArguments = {
...parseDefaultArgs(baseOptions),
...filterExplicitArgs(explicitArgs, baseOptions),
};
// `channel` only applies when Chrome is launched by channel. Leaving it
// unset otherwise keeps it out of telemetry (computeFlagUsage).
const launchesByChannel =
!resolvedArgs.browserUrl &&
!resolvedArgs.wsEndpoint &&
!resolvedArgs.executablePath;
if (!launchesByChannel && explicitArgs.channel === undefined) {
delete resolvedArgs.channel;
}
if (isViaCli) {
if (
resolvedArgs.filesystemRoot === DEFAULT_FILESYSTEM_ROOT &&
explicitArgs.allowUnrestrictedPaths !== false
) {
resolvedArgs.allowUnrestrictedPaths = true;
resolvedArgs.filesystemRoot = undefined;
}
const connectsToExistingBrowser =
resolvedArgs.autoConnect ||
resolvedArgs.browserUrl ||
resolvedArgs.wsEndpoint;
if (
explicitArgs.isolated === undefined &&
resolvedArgs.userDataDir === undefined &&
!connectsToExistingBrowser
) {
resolvedArgs.isolated = true;
}
if (
resolvedArgs.categoryExtensions === undefined &&
!connectsToExistingBrowser
) {
resolvedArgs.categoryExtensions = true;
}
}
if (env['CI'] || env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
if (!options?.suppressUsageStatisticsNotice) {
console.error(
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
);
}
resolvedArgs.usageStatistics = false;
}
if (
resolvedArgs.categoryExperimentalThirdParty === undefined &&
env['CHROME_DEVTOOLS_MCP_CATEGORY_EXPERIMENTAL_THIRD_PARTY']
) {
resolvedArgs.categoryExperimentalThirdParty = true;
}
return resolvedArgs;
}
/**
* Step 3–5: merges CLI and config-file layers (`CLI > config file`),
* validates conflicts and implications, and applies defaults.
*/
static create(options: ServerConfigCreateOptions): ServerConfig {
const explicitArgs: Partial<ParsedArguments> = {
...options.fileArgs,
...options.cliArgs,
...(options.configPath ? {config: options.configPath} : {}),
};
ServerConfig.validateConflicts(explicitArgs);
ServerConfig.validateImplications(explicitArgs);
const resolved = ServerConfig.applyDefaults(explicitArgs, {
env: options.env,
suppressUsageStatisticsNotice: options.suppressUsageStatisticsNotice,
});
return new ServerConfig({
cliArgs: options.cliArgs,
fileArgs: options.fileArgs,
configPath: options.configPath,
env: options.env,
resolved,
});
}
get cliArgs(): Readonly<Partial<ParsedArguments>> {
return this.#cliArgs;
}
get fileArgs(): Readonly<Partial<ParsedArguments>> {
return this.#fileArgs;
}
get configPath(): string | undefined {
return this.#configPath;
}
get resolved(): Readonly<ParsedArguments> {
return this.#resolved;
}
/**
* Produces a new {@link ServerConfig} that takes reloadable options from
* `next` and keeps restart-required options from this config, preserving
* `CLI > config file > defaults` precedence.
*/
mergeReloadable(
next: ServerConfig,
options?: {env?: NodeJS.ProcessEnv},
): ServerConfig {
const mergedFileArgs = mergeLayer(this.#fileArgs, next.#fileArgs);
const mergedCliArgs = mergeLayer(this.#cliArgs, next.#cliArgs);
return ServerConfig.create({
cliArgs: mergedCliArgs,
fileArgs: mergedFileArgs,
configPath: this.#configPath,
env: options?.env ?? this.#env,
suppressUsageStatisticsNotice: true,
});
}
/**
* Commits a previously validated {@link ServerConfig} snapshot in place so
* all shared {@link ReadonlyServerConfig} readers observe the new values.
*/
commit(next: ServerConfig): void {
this.#cliArgs = next.#cliArgs;
this.#fileArgs = next.#fileArgs;
this.#resolved = next.#resolved;
}
}
+41 -42
View File
@@ -240,53 +240,39 @@ export const mcpOptions = {
},
} satisfies Record<string, McpOption>;
export function getMcpOptionsForViaCli(): Record<
keyof typeof mcpOptions,
YargsOptions
> {
if (!('default' in mcpOptions.headless)) {
throw new Error('headless cli option unexpectedly does not have a default');
}
if (!('default' in mcpOptions.experimentalStructuredContent)) {
throw new Error(
'experimentalStructuredContent cli option unexpectedly does not have a default',
);
}
return {
...mcpOptions,
headless: {
...mcpOptions.headless,
default: true,
},
memoryDebugging: {
...mcpOptions.memoryDebugging,
default: true,
},
categoryExtensions: {
...mcpOptions.categoryExtensions,
defaultDescription:
'true unless autoConnect, browserUrl or wsEndpoint is set',
},
experimentalStructuredContent: {
...mcpOptions.experimentalStructuredContent,
default: true,
},
isolated: {
...mcpOptions.isolated,
description:
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.',
defaultDescription:
'true unless userDataDir, autoConnect, browserUrl or wsEndpoint is set',
},
};
}
export const mcpOptionsForViaCli = {
...mcpOptions,
headless: {
...mcpOptions.headless,
default: true,
},
memoryDebugging: {
...mcpOptions.memoryDebugging,
default: true,
},
categoryExtensions: {
...mcpOptions.categoryExtensions,
defaultDescription:
'true unless autoConnect, browserUrl or wsEndpoint is set',
},
experimentalStructuredContent: {
...mcpOptions.experimentalStructuredContent,
default: true,
},
isolated: {
...mcpOptions.isolated,
description:
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.',
defaultDescription:
'true unless userDataDir, autoConnect, browserUrl or wsEndpoint is set',
},
} satisfies Record<keyof typeof mcpOptions, YargsOptions>;
export function getCliOptions(): Partial<
Record<keyof typeof mcpOptions, YargsOptions>
> {
const options: Partial<Record<keyof typeof mcpOptions, YargsOptions>> =
withoutDefaults(getMcpOptionsForViaCli());
withoutDefaults(mcpOptionsForViaCli);
// Missing CLI serialization.
delete options.viewport;
@@ -397,6 +383,19 @@ export function withoutDefaults(
return result;
}
export function stripYargsPositionalArgs<T extends {_?: unknown; $0?: unknown}>(
parsed: T,
): {
[
K in keyof T as string extends K ? never : K extends '_' | '$0' ? never : K
]: T[K];
} {
const copy = {...parsed};
delete copy._;
delete copy.$0;
return copy;
}
export function isReloadableOption(name: keyof typeof mcpOptions): boolean {
const option: McpOption = mcpOptions[name];
return option.reloadable === true;
+353
View File
@@ -0,0 +1,353 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import fs from 'node:fs';
import {describe, it} from 'node:test';
import sinon from 'sinon';
import {ConfigLocator} from '../../src/config/ConfigLocator.js';
import {ConfigParser} from '../../src/config/ConfigParser.js';
import {DEFAULT_FILESYSTEM_ROOT} from '../../src/config/mcp-options.js';
import {
type ReadonlyServerConfig,
ServerConfig,
} from '../../src/config/ServerConfig.js';
import {createTempFile} from '../utils.js';
describe('ServerConfig', () => {
function parseConfig(argv: string[] = [], env: NodeJS.ProcessEnv = {}) {
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(undefined);
return new ConfigParser(
'0.0.0',
['node', 'main.js', ...argv],
env,
false,
locator,
).parseConfig();
}
describe('validateConflicts', () => {
it('accepts a single argument from a conflict group', () => {
ServerConfig.validateConflicts({browserUrl: 'http://localhost:9222'});
});
it('ignores false and undefined values', () => {
ServerConfig.validateConflicts({
browserUrl: 'http://localhost:9222',
wsEndpoint: undefined,
categoryExtensions: false,
});
});
it('rejects two arguments from the same group', () => {
assert.throws(
() =>
ServerConfig.validateConflicts({
browserUrl: 'http://localhost:9222',
channel: 'canary',
}),
/Arguments channel and browserUrl are mutually exclusive/,
);
});
});
describe('validateImplications', () => {
it('accepts when implying key is not set', () => {
ServerConfig.validateImplications({wsEndpoint: 'ws://localhost:9222'});
});
it('accepts when both implying and implied keys are set', () => {
ServerConfig.validateImplications({
wsHeaders: {Auth: 'token'},
wsEndpoint: 'ws://localhost:9222',
});
});
it('rejects when implying key is set but implied key is missing', () => {
assert.throws(
() => ServerConfig.validateImplications({wsHeaders: {Auth: 'token'}}),
/Implications failed:\n {2}wsHeaders -> wsEndpoint/,
);
});
it('rejects when implying key is set but implied key is negated', () => {
assert.throws(
() =>
ServerConfig.validateImplications({
experimentalFfmpegPath: '/bin/ffmpeg',
experimentalScreencast: false,
}),
/Implications failed:\n {2}experimentalFfmpegPath -> experimentalScreencast/,
);
});
});
describe('applyDefaults', () => {
it('keeps explicit values and fills in defaults', () => {
const args = ServerConfig.applyDefaults({headless: true}, {env: {}});
assert.strictEqual(args.headless, true);
assert.strictEqual(args.isolated, false);
assert.strictEqual(args.channel, 'stable');
assert.strictEqual(args.categoryExtensions, undefined);
});
it('drops unknown CLI keys that are not in mcpOptions', () => {
sinon.stub(console, 'error');
const config = parseConfig(['--unknownFlag=value']);
assert.strictEqual('unknownFlag' in config.resolved, false);
});
for (const explicitArgs of [
{browserUrl: 'http://localhost:9222'},
{wsEndpoint: 'ws://localhost:9222'},
{executablePath: '/tmp/chrome'},
]) {
it(`does not default channel with ${Object.keys(explicitArgs)[0]}`, () => {
assert.strictEqual(
ServerConfig.applyDefaults(explicitArgs, {env: {}}).channel,
undefined,
);
});
}
it('applies viaCli defaults when launching a browser', () => {
const args = ServerConfig.applyDefaults(
{
viaCli: true,
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
},
{env: {}},
);
assert.strictEqual(args.headless, true);
assert.strictEqual(args.isolated, true);
assert.strictEqual(args.categoryExtensions, true);
assert.strictEqual(args.allowUnrestrictedPaths, true);
assert.strictEqual(args.filesystemRoot, undefined);
});
it('keeps the default filesystem root for viaCli with explicit allowUnrestrictedPaths=false', () => {
const args = ServerConfig.applyDefaults(
{
viaCli: true,
allowUnrestrictedPaths: false,
},
{env: {}},
);
assert.strictEqual(args.allowUnrestrictedPaths, false);
assert.deepStrictEqual(args.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
});
it('does not enable isolated or extensions for viaCli with browserUrl', () => {
const args = ServerConfig.applyDefaults(
{
viaCli: true,
browserUrl: 'http://localhost:9222',
},
{env: {}},
);
assert.strictEqual(args.isolated, false);
assert.strictEqual(args.categoryExtensions, undefined);
});
it('turns off usage statistics in CI', () => {
sinon.stub(console, 'error');
const args = ServerConfig.applyDefaults(
{usageStatistics: true},
{env: {CI: 'true'}},
);
assert.strictEqual(args.usageStatistics, false);
});
it('prints the usage statistics notice once across reloads', () => {
const consoleError = sinon.stub(console, 'error');
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(undefined);
const ciParser = new ConfigParser(
'0.0.0',
['node', 'main.js'],
{CI: 'true'},
false,
locator,
);
ciParser.parseConfig();
const reloaded = ciParser.reloadConfig();
assert.strictEqual(reloaded.resolved.usageStatistics, false);
sinon.assert.calledOnce(consoleError);
});
it('prints the usage statistics notice after a failed first parse', () => {
using configFile = createTempFile(
JSON.stringify({
browserUrl: 'http://localhost:9222',
channel: 'canary',
}),
'cd4a.conflict.config.json',
);
const consoleError = sinon.stub(console, 'error');
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(configFile.path);
const ciParser = new ConfigParser(
'0.0.0',
['node', 'main.js'],
{CI: 'true'},
false,
locator,
);
assert.throws(
() => ciParser.parseConfig(),
/Arguments channel and browserUrl are mutually exclusive/,
);
sinon.assert.notCalled(consoleError);
fs.writeFileSync(configFile.path, JSON.stringify({headless: true}));
const reloaded = ciParser.reloadConfig();
assert.strictEqual(reloaded.resolved.usageStatistics, false);
sinon.assert.calledOnceWithExactly(
consoleError,
"turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.",
);
});
});
describe('mergeReloadable', () => {
it('applies reloadable options', () => {
const merged = parseConfig().mergeReloadable(
parseConfig([
'--memoryDebugging',
'--no-category-network',
'--no-file-navigations',
'--slim',
]),
).resolved;
assert.strictEqual(merged.memoryDebugging, true);
assert.strictEqual(merged.categoryNetwork, false);
assert.strictEqual(merged.fileNavigations, false);
assert.strictEqual(merged.slim, true);
});
it('keeps restart-required options', () => {
const merged = parseConfig().mergeReloadable(
parseConfig([
'--categoryExtensions',
'--blockedUrlPattern=https://example.com/*',
'--experimentalDevtools',
]),
).resolved;
assert.strictEqual(merged.categoryExtensions, undefined);
assert.strictEqual(merged.blockedUrlPattern, undefined);
assert.strictEqual(merged.experimentalDevtools, false);
});
it('keeps options derived from restart-required options', () => {
const previous = parseConfig(['--viaCli']);
const next = parseConfig([
'--viaCli',
'--browserUrl=http://127.0.0.1:9222',
]);
const merged = previous.mergeReloadable(next).resolved;
assert.strictEqual(merged.categoryExtensions, true);
assert.strictEqual(merged.browserUrl, undefined);
});
it('preserves CLI precedence over reloaded config file values', () => {
using configFile = createTempFile(
JSON.stringify({memoryDebugging: false, performanceCrux: true}),
'cd4a.config.json',
);
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(configFile.path);
const parser = new ConfigParser(
'0.0.0',
['node', 'main.js', '--memoryDebugging'],
{},
false,
locator,
);
const initial = parser.parseConfig();
fs.writeFileSync(
configFile.path,
JSON.stringify({memoryDebugging: false, performanceCrux: false}),
);
const reloaded = parser.reloadConfig();
const merged = initial.mergeReloadable(reloaded).resolved;
assert.strictEqual(merged.memoryDebugging, true);
assert.strictEqual(merged.performanceCrux, false);
});
it('rejects reloadable options that conflict with startup restart-required options', () => {
const previous = parseConfig(['--browserUrl=http://127.0.0.1:9222']);
const next = parseConfig(['--categoryPwa']);
assert.throws(
() => previous.mergeReloadable(next),
/Arguments categoryPwa and browserUrl are mutually exclusive/,
);
});
it('does not conflict with default options', () => {
const merged = parseConfig().mergeReloadable(
parseConfig(['--allow-unrestricted-paths']),
).resolved;
assert.strictEqual(merged.allowUnrestrictedPaths, true);
assert.strictEqual(merged.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
});
it('applies env overrides when merging reloadable options', () => {
const initial = parseConfig();
const next = parseConfig();
const mergedWithCi = initial.mergeReloadable(next, {
env: {CI: 'true'},
}).resolved;
const mergedWithoutCi = initial.mergeReloadable(next).resolved;
assert.strictEqual(mergedWithCi.usageStatistics, false);
assert.strictEqual(mergedWithoutCi.usageStatistics, true);
});
});
describe('ServerConfig.commit()', () => {
it('updates cliArgs, fileArgs, and resolved in place', () => {
const initial = ServerConfig.create({
cliArgs: {headless: true},
fileArgs: {memoryDebugging: false},
env: {},
suppressUsageStatisticsNotice: true,
});
const readonlyView: ReadonlyServerConfig = initial;
const next = initial.mergeReloadable(
ServerConfig.create({
cliArgs: {headless: true, slim: true},
fileArgs: {memoryDebugging: true},
env: {},
suppressUsageStatisticsNotice: true,
}),
);
initial.commit(next);
assert.deepStrictEqual(initial.cliArgs, {headless: true, slim: true});
assert.deepStrictEqual(initial.fileArgs, {memoryDebugging: true});
assert.strictEqual(readonlyView.resolved.slim, true);
assert.strictEqual(readonlyView.resolved.memoryDebugging, true);
assert.strictEqual(readonlyView.resolved.headless, true);
});
});
});
-211
View File
@@ -12,7 +12,6 @@ import sinon from 'sinon';
import {ConfigLocator} from '../../src/config/ConfigLocator.js';
import {ConfigParser} from '../../src/config/ConfigParser.js';
import {DEFAULT_FILESYSTEM_ROOT} from '../../src/config/mcp-options.js';
import {createTempFile} from '../utils.js';
describe('mcp-options steps', () => {
@@ -70,137 +69,6 @@ describe('mcp-options steps', () => {
});
});
describe('validateConflicts', () => {
it('accepts a single argument from a conflict group', () => {
ConfigParser.validateConflicts({browserUrl: 'http://localhost:9222'});
});
it('ignores false and undefined values', () => {
ConfigParser.validateConflicts({
browserUrl: 'http://localhost:9222',
wsEndpoint: undefined,
categoryExtensions: false,
});
});
it('rejects two arguments from the same group', () => {
assert.throws(
() =>
ConfigParser.validateConflicts({
browserUrl: 'http://localhost:9222',
channel: 'canary',
}),
/Arguments channel and browserUrl are mutually exclusive/,
);
});
describe('validateImplications', () => {
it('accepts when implying key is not set', () => {
ConfigParser.validateImplications({wsEndpoint: 'ws://localhost:9222'});
});
it('accepts when both implying and implied keys are set', () => {
ConfigParser.validateImplications({
wsHeaders: {Auth: 'token'},
wsEndpoint: 'ws://localhost:9222',
});
});
it('rejects when implying key is set but implied key is missing', () => {
assert.throws(
() => ConfigParser.validateImplications({wsHeaders: {Auth: 'token'}}),
/Implications failed:\n {2}wsHeaders -> wsEndpoint/,
);
});
it('rejects when implying key is set but implied key is negated', () => {
assert.throws(
() =>
ConfigParser.validateImplications({
experimentalFfmpegPath: '/bin/ffmpeg',
experimentalScreencast: false,
}),
/Implications failed:\n {2}experimentalFfmpegPath -> experimentalScreencast/,
);
});
});
});
describe('applyDefaults', () => {
it('keeps explicit values and fills in defaults', () => {
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
headless: true,
});
assert.strictEqual(args.headless, true);
assert.strictEqual(args.isolated, false);
assert.strictEqual(args.channel, 'stable');
assert.strictEqual(args.categoryExtensions, undefined);
});
for (const explicitArgs of [
{browserUrl: 'http://localhost:9222'},
{wsEndpoint: 'ws://localhost:9222'},
{executablePath: '/tmp/chrome'},
]) {
it(`does not default channel with ${Object.keys(explicitArgs)[0]}`, () => {
assert.strictEqual(
new ConfigParser('0.0.0', [], {}).applyDefaults(explicitArgs).channel,
undefined,
);
});
}
it('applies viaCli defaults when launching a browser', () => {
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
viaCli: true,
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
});
assert.strictEqual(args.headless, true);
assert.strictEqual(args.isolated, true);
assert.strictEqual(args.categoryExtensions, true);
assert.strictEqual(args.allowUnrestrictedPaths, true);
assert.strictEqual(args.filesystemRoot, undefined);
});
it('keeps the default filesystem root for viaCli with explicit allowUnrestrictedPaths=false', () => {
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
viaCli: true,
allowUnrestrictedPaths: false,
});
assert.strictEqual(args.allowUnrestrictedPaths, false);
assert.deepStrictEqual(args.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
});
it('does not enable isolated or extensions for viaCli with browserUrl', () => {
const args = new ConfigParser('0.0.0', [], {}).applyDefaults({
viaCli: true,
browserUrl: 'http://localhost:9222',
});
assert.strictEqual(args.isolated, false);
assert.strictEqual(args.categoryExtensions, undefined);
});
it('turns off usage statistics in CI', () => {
sinon.stub(console, 'error');
const args = new ConfigParser('0.0.0', [], {
CI: 'true',
}).applyDefaults({
usageStatistics: true,
});
assert.strictEqual(args.usageStatistics, false);
});
it('prints the usage statistics notice once', () => {
const consoleError = sinon.stub(console, 'error');
const parser = new ConfigParser('0.0.0', [], {CI: 'true'});
parser.applyDefaults({});
const args = parser.applyDefaults({});
assert.strictEqual(args.usageStatistics, false);
sinon.assert.calledOnce(consoleError);
});
});
describe('config discovery', () => {
it('does not discover a config file when locator returns undefined', () => {
const locator = new ConfigLocator();
@@ -308,83 +176,4 @@ describe('mcp-options steps', () => {
assert.throws(() => parser.reload(), /Invalid JSON config file/);
});
});
describe('mergeReloadableOptions', () => {
function parseArgs(argv: string[] = []) {
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(undefined);
return new ConfigParser(
'0.0.0',
['node', 'main.js', ...argv],
{},
false,
locator,
).parse();
}
it('applies reloadable options', () => {
const merged = ConfigParser.mergeReloadableOptions(
parseArgs(),
parseArgs([
'--memoryDebugging',
'--no-category-network',
'--no-file-navigations',
'--slim',
]),
);
assert.strictEqual(merged.memoryDebugging, true);
assert.strictEqual(merged.categoryNetwork, false);
assert.strictEqual(merged.fileNavigations, false);
assert.strictEqual(merged.slim, true);
});
it('keeps restart-required options', () => {
const merged = ConfigParser.mergeReloadableOptions(
parseArgs(),
parseArgs([
'--categoryExtensions',
'--blockedUrlPattern=https://example.com/*',
'--experimentalDevtools',
]),
);
assert.strictEqual(merged.categoryExtensions, undefined);
assert.strictEqual(merged.blockedUrlPattern, undefined);
assert.strictEqual(merged.experimentalDevtools, false);
});
it('keeps options derived from restart-required options', () => {
const previous = parseArgs(['--viaCli']);
const next = parseArgs([
'--viaCli',
'--browserUrl=http://127.0.0.1:9222',
]);
const merged = ConfigParser.mergeReloadableOptions(previous, next);
assert.strictEqual(merged.categoryExtensions, true);
assert.strictEqual(merged.browserUrl, undefined);
});
it('rejects reloadable options that conflict with startup restart-required options', () => {
const previous = parseArgs(['--browserUrl=http://127.0.0.1:9222']);
const next = parseArgs(['--categoryPwa']);
assert.throws(
() => ConfigParser.mergeReloadableOptions(previous, next),
/Arguments categoryPwa and browserUrl are mutually exclusive/,
);
});
it('does not conflict with default options', () => {
const merged = ConfigParser.mergeReloadableOptions(
parseArgs(),
parseArgs(['--allow-unrestricted-paths']),
);
assert.strictEqual(merged.allowUnrestrictedPaths, true);
assert.strictEqual(merged.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
});
});
});
+6 -8
View File
@@ -29,8 +29,8 @@ import sinon from 'sinon';
import {
type ParsedArguments,
ConfigParser,
} from '../src/config/ConfigParser.js';
ServerConfig,
} from '../src/config/ServerConfig.js';
import {McpContext} from '../src/McpContext.js';
import {McpPage} from '../src/McpPage.js';
import {McpResponse} from '../src/McpResponse.js';
@@ -1307,12 +1307,10 @@ export function createMockContextAnalysisResult(): DevTools.HeapSnapshotModel.He
export function createMockParsedArguments(
options: Partial<ParsedArguments> = {},
): ParsedArguments {
const defaultArgs = new ConfigParser(
'0.0.0',
['node', 'main.js'],
process.env,
false,
).parse();
const defaultArgs = ServerConfig.applyDefaults(
{},
{suppressUsageStatisticsNotice: true},
);
return {...defaultArgs, ...options};
}