feat(config): add --watchConfig to reload config file changes (#3248)

Adds `--watchConfig` (`watchConfig: boolean`, default `false`) to watch
the resolved configuration file and reload reloadable options when it
changes:

- Adds `--watchConfig` in `mcpOptions` and regenerates the
CLI/configuration documentation and telemetry metrics.
- Adds `ServerConfig.getRestartRequiredChanges(next)` to identify
non-reloadable options that changed on reload.
- Adds `McpServer.watchConfig(reload)`, which starts a `ConfigWatcher`
on `ServerConfig.configPath`, calls `reload()` and `applyConfig()` on
change, logs a warning to `stderr` when restart-required options change,
keeps the previous configuration if reloading or validation fails, and
disposes the watcher on `McpServer.close()`.
- Wires `--watchConfig` into both the stdio server entrypoint
(`chrome-devtools-mcp-main.ts`) and the daemon server (`daemon.ts`).
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-20 09:20:44 +00:00
1 parent 35abe350a4
commit b48561f6ca
10 files changed
+465 -6

No files matched your search

+19
View File
@@ -243,6 +243,11 @@ The Chrome DevTools MCP server supports the following configuration option:
Path to JSON configuration file.
- **Type:** string
- **`--watchConfig`/ `--watch-config`**
Watch the config file for changes and apply them without restarting. Tools (except --categoryExtensions) are enabled, disabled and updated on the fly; options used to launch or connect to the browser still require a restart.
- **Type:** boolean
- **Default:** `false`
<!-- END AUTO GENERATED OPTIONS -->
Pass them via the `args` property in the JSON configuration. For example:
@@ -296,6 +301,20 @@ Set the `CHROME_DEVTOOLS_MCP_NO_CONFIG_DISCOVERY` env variable to turn off the
search for config files, for example in tests. A config file passed via
`--config` is still used.
### Applying changes without a restart
Pass `--watchConfig` to reload the config file whenever it changes. Watching is
off by default. On a change, the server re-reads the file, updates the list of
available tools, and notifies the MCP client with
`notifications/tools/list_changed`. If the new file is invalid, the server logs
the error to stderr and keeps the previous configuration.
Tool categories (except `categoryExtensions`) and tool-specific options apply
immediately. Options that configure the browser, such as `headless`, `channel`,
`viewport`, `blockedUrlPattern`, or `categoryExtensions`, only apply after
restarting chrome-devtools-mcp. The server logs a message to stderr when a
change requires a restart.
### Locking down the configuration file from agent access
> [!WARNING]
+3
View File
@@ -85,6 +85,9 @@ process.on('SIGHUP', () => {
});
const server = await serverPromise;
if (args.watchConfig) {
server.watchConfig(() => configParser.reloadConfig());
}
const transport = new StdioServerTransport();
await server.connect(transport);
logger?.('Chrome DevTools MCP Server connected');
+22
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {isDeepStrictEqual} from 'node:util';
import type {InferredOptionTypes} from '../third_party/cli.js';
import {yargs} from '../third_party/cli.js';
@@ -306,6 +308,26 @@ export class ServerConfig implements ReadonlyServerConfig {
});
}
/**
* Returns the restart-required options whose resolved values differ between
* this config and `next`.
*/
getRestartRequiredChanges(next: ServerConfig): Array<keyof ParsedArguments> {
const changed: Array<keyof ParsedArguments> = [];
for (const name of Object.keys(mcpOptions)) {
if (
isMcpOptionKey(name) &&
!isReloadableOption(name) &&
(!isDeepStrictEqual(this.#fileArgs[name], next.#fileArgs[name]) ||
!isDeepStrictEqual(this.#cliArgs[name], next.#cliArgs[name])) &&
!isDeepStrictEqual(this.#resolved[name], next.#resolved[name])
) {
changed.push(name);
}
}
return changed;
}
/**
* Commits a previously validated {@link ServerConfig} snapshot in place so
* all shared {@link ReadonlyServerConfig} readers observe the new values.
+6
View File
@@ -238,6 +238,12 @@ export const mcpOptions = {
return path.resolve(configPath);
},
},
watchConfig: {
type: 'boolean',
default: false,
describe:
'Watch the config file for changes and apply them without restarting. Tools (except --categoryExtensions) are enabled, disabled and updated on the fly; options used to launch or connect to the browser still require a restart.',
},
} satisfies Record<string, McpOption>;
export const mcpOptionsForViaCli = {
+3
View File
@@ -151,6 +151,9 @@ async function setupMCPServer(): Promise<void> {
browserManager,
logFile,
});
if (args.watchConfig) {
mcpServer.watchConfig(() => configParser.reloadConfig());
}
ClearcutLogger.get()?.setClientName(DAEMON_CLIENT_NAME);
void ClearcutLogger.get()?.logDailyActiveIfNeeded();
void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, mcpOptions));
+67 -1
View File
@@ -10,6 +10,7 @@ import {pathToFileURL} from 'node:url';
import {BrowserManager} from './BrowserManager.js';
import type {ParsedArguments} from './config/ConfigParser.js';
import {ConfigWatcher} from './config/ConfigWatcher.js';
import {DEFAULT_FILESYSTEM_ROOT} from './config/mcp-options.js';
import {ServerConfig} from './config/ServerConfig.js';
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
@@ -32,6 +33,7 @@ import {ToolHandler} from './ToolHandler.js';
import type {AnyToolDefinition} from './tools/ToolDefinition.js';
import {createTools} from './tools/tools.js';
import {logCruxDisclaimer} from './utils/disclaimers.js';
import {getErrorMessage} from './utils/errorHandling.js';
import {logger} from './utils/logger.js';
import {VERSION} from './version.js';
@@ -76,6 +78,7 @@ export class McpServer {
*/
#awaitedInitialRoots = false;
#toolMutex = new Mutex();
#configWatcher?: ConfigWatcher;
#tools = new Map<string, ToolEntry>();
private constructor(config: ServerConfig, options: McpServerOptions) {
@@ -178,8 +181,11 @@ export class McpServer {
*/
async applyConfig(config: ServerConfig): Promise<void> {
using guard = await this.#toolMutex.acquire();
await this.#applyConfigLocked(this.#config.mergeReloadable(config));
}
async #applyConfigLocked(nextConfig: ServerConfig): Promise<void> {
const previousArgs = this.#config.resolved;
const nextConfig = this.#config.mergeReloadable(config);
const nextArgs = nextConfig.resolved;
// Build everything before committing so that a throwing tool factory
// leaves the server untouched.
@@ -214,10 +220,70 @@ export class McpServer {
await this.#context.onConfigChanged(previousArgs);
}
/**
* Watches the config file and applies its changes to the running server.
*
* @param reload Re-reads the config file and returns the new configuration.
*/
watchConfig(reload: () => ServerConfig): void {
const configPath = this.#config.configPath;
if (!configPath) {
console.error(
'--watchConfig has no effect because no config file was found.',
);
return;
}
this.#configWatcher?.dispose();
this.#configWatcher = undefined;
const watcher = new ConfigWatcher(configPath, () =>
this.#reloadConfig(watcher, configPath, reload),
);
try {
watcher.start();
} catch (err) {
watcher.dispose();
console.error(`Failed to watch ${configPath}: ${getErrorMessage(err)}`);
return;
}
this.#configWatcher = watcher;
}
async #reloadConfig(
watcher: ConfigWatcher,
configPath: string,
reload: () => ServerConfig,
): Promise<void> {
using guard = await this.#toolMutex.acquire();
if (this.#configWatcher !== watcher) {
return;
}
let config: ServerConfig;
let nextConfig: ServerConfig;
try {
config = reload();
nextConfig = this.#config.mergeReloadable(config);
} catch (err) {
const message = `Keeping the previous configuration, failed to reload ${configPath}: ${getErrorMessage(err)}`;
logger?.(message);
console.error(message);
return;
}
await this.#applyConfigLocked(nextConfig);
const restartRequired = this.#config.getRestartRequiredChanges(config);
logger?.(`Applied config changes from ${configPath}`);
if (restartRequired.length > 0) {
const message = `Restart chrome-devtools-mcp to fully apply changes to ${restartRequired.map(name => `--${name}`).join(', ')} from ${configPath}.`;
logger?.(message);
console.error(message);
}
}
/**
* Closes the MCP connection and disposes internal context/listeners.
*/
async close(): Promise<void> {
this.#configWatcher?.dispose();
this.#configWatcher = undefined;
try {
this.#context.dispose();
} catch (err) {
+8
View File
@@ -470,5 +470,13 @@
{
"name": "screencast_fps_present",
"flagType": "boolean"
},
{
"name": "watch_config_present",
"flagType": "boolean"
},
{
"name": "watch_config",
"flagType": "boolean"
}
]
+278 -5
View File
@@ -15,13 +15,16 @@ import sinon from 'sinon';
import {BrowserManager} from '../src/BrowserManager.js';
import {getOffByDefaultCategories} from '../src/config/category-options.js';
import {ConfigLocator} from '../src/config/ConfigLocator.js';
import {ConfigParser} from '../src/config/ConfigParser.js';
import {ConfigWatcher} from '../src/config/ConfigWatcher.js';
import {mcpOptions} from '../src/config/mcp-options.js';
import {McpServer} from '../src/index.js';
import {McpContext} from '../src/McpContext.js';
import {ClearcutLogger} from '../src/telemetry/ClearcutLogger.js';
import type {ToolCategory} from '../src/tools/categories.js';
import type {ToolDefinition} from '../src/tools/ToolDefinition.js';
import {saveLogsToFile} from '../src/utils/logger.js';
import {createMockMcpContext, createMockPuppeteerBrowser} from './mocks.js';
import {createTempFile} from './utils.js';
@@ -31,10 +34,22 @@ describe('McpServer', () => {
ClearcutLogger.resetForTesting();
});
function createConfigParser(argv: string[] = []) {
const locator = new ConfigLocator();
sinon.stub(locator, 'locate').returns(undefined);
return new ConfigParser(
'1.0.0',
['node', 'script.js', ...argv],
{
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
},
false,
locator,
);
}
function parseConfig(argv: string[] = []) {
return new ConfigParser('1.0.0', ['node', 'script.js', ...argv], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parseConfig();
return createConfigParser(argv).parseConfig();
}
async function createTestServer(
@@ -52,9 +67,10 @@ describe('McpServer', () => {
sinon.stub(McpContext, 'create').returns(context);
}
const config = parseConfig(extraArgs);
const configParser = createConfigParser(extraArgs);
const config = configParser.parseConfig();
const server = await McpServer.from(config, {browserManager});
return {server, browserManager, context, config};
return {server, browserManager, context, config, configParser};
}
async function connectClient(server: McpServer): Promise<Client> {
@@ -528,6 +544,263 @@ describe('McpServer', () => {
await server.close();
});
});
describe('watchConfig', () => {
async function watchTestServer(configPath: string) {
const logStream = sinon.createStubInstance(fs.WriteStream);
sinon.stub(fs, 'createWriteStream').returns(logStream);
saveLogsToFile('test.log');
let watchListener: fs.WatchListener<string> | undefined;
const watchStub = sinon
.stub(fs, 'watch')
.callsFake(
(
filename: fs.PathLike,
options?:
| fs.WatchOptions
| BufferEncoding
| fs.WatchListener<string>
| null,
listener?: fs.WatchListener<string>,
) => {
assert.ok(listener);
watchListener = listener;
return watchStub.wrappedMethod(filename, () => {}).unref();
},
);
const clock = sinon.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout'],
});
const {server, context, config, configParser} = await createTestServer([
'--config',
configPath,
]);
server.watchConfig(() => configParser.reloadConfig());
logStream.write.resetHistory();
async function updateConfigFile(content: string) {
fs.writeFileSync(configPath, content);
assert.ok(watchListener);
watchListener('change', path.basename(configPath));
await clock.tickAsync(200);
}
return {
server,
context,
config,
logStream,
updateConfigFile,
async [Symbol.asyncDispose]() {
await server.close();
},
};
}
it('does not start a watcher when no config file was resolved', async () => {
const startStub = sinon.stub(ConfigWatcher.prototype, 'start');
const {server, configParser} = await createTestServer();
const consoleError = sinon.stub(console, 'error');
server.watchConfig(() => configParser.reloadConfig());
sinon.assert.notCalled(startStub);
sinon.assert.calledOnceWithExactly(
consoleError,
'--watchConfig has no effect because no config file was found.',
);
});
it('logs an error and disposes the watcher when starting the watcher fails', async () => {
using configFile = createTempFile('{}', 'cd4a.watch.error.json');
sinon
.stub(ConfigWatcher.prototype, 'start')
.throws(new Error('watch limit reached'));
const disposeStub = sinon.stub(ConfigWatcher.prototype, 'dispose');
const {server, configParser} = await createTestServer([
'--config',
configFile.path,
]);
const consoleError = sinon.stub(console, 'error');
server.watchConfig(() => configParser.reloadConfig());
sinon.assert.calledOnceWithExactly(disposeStub);
sinon.assert.calledOnceWithExactly(
consoleError,
`Failed to watch ${configFile.path}: watch limit reached`,
);
});
it('disposes the previous watcher when called again and on close', async () => {
using configFile = createTempFile('{}', 'cd4a.watch.lifecycle.json');
const startStub = sinon.stub(ConfigWatcher.prototype, 'start');
const disposeStub = sinon.stub(ConfigWatcher.prototype, 'dispose');
const {server, configParser} = await createTestServer([
'--config',
configFile.path,
]);
server.watchConfig(() => configParser.reloadConfig());
sinon.assert.calledOnceWithExactly(startStub);
sinon.assert.notCalled(disposeStub);
server.watchConfig(() => configParser.reloadConfig());
sinon.assert.calledTwice(startStub);
sinon.assert.calledOnceWithExactly(disposeStub);
await server.close();
sinon.assert.calledTwice(disposeStub);
await server.close();
sinon.assert.calledTwice(disposeStub);
});
it('applies reloadable config changes when the config file changes', async () => {
using configFile = createTempFile(
JSON.stringify({memoryDebugging: false}),
'cd4a.watch.reloadable.json',
);
await using watched = await watchTestServer(configFile.path);
const consoleError = sinon.stub(console, 'error');
await watched.updateConfigFile(JSON.stringify({memoryDebugging: true}));
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
assert.ok(
(await listServerToolNames(watched.server)).includes(
'get_heapsnapshot_summary',
),
);
sinon.assert.notCalled(consoleError);
});
it('does not apply a queued reload after the server is closed', async () => {
using configFile = createTempFile(
JSON.stringify({memoryDebugging: false}),
'cd4a.watch.closed.json',
);
await using watched = await watchTestServer(configFile.path);
const {promise: toolBrowser, resolve: releaseToolCall} =
Promise.withResolvers<ReturnType<typeof createMockPuppeteerBrowser>>();
const {promise: toolEntered, resolve: markToolEntered} =
Promise.withResolvers<void>();
watched.context.ensureBrowser.onFirstCall().callsFake(async () => {
markToolEntered();
return await toolBrowser;
});
const toolCall = watched.server.callTool('list_pages');
await toolEntered;
const reloadPromise = watched.updateConfigFile(
JSON.stringify({memoryDebugging: true}),
);
await watched.server.close();
releaseToolCall(createMockPuppeteerBrowser());
await toolCall;
await reloadPromise;
assert.strictEqual(watched.config.resolved.memoryDebugging, false);
sinon.assert.notCalled(watched.context.onConfigChanged);
});
it('logs a restart notice when restart-required options change', async () => {
using configFile = createTempFile(
JSON.stringify({
headless: false,
memoryDebugging: false,
}),
'cd4a.watch.restart.json',
);
await using watched = await watchTestServer(configFile.path);
const consoleError = sinon.stub(console, 'error');
await watched.updateConfigFile(
JSON.stringify({
headless: true,
memoryDebugging: true,
categoryExtensions: true,
}),
);
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
assert.strictEqual(watched.config.resolved.headless, false);
assert.strictEqual(watched.config.resolved.categoryExtensions, undefined);
const expectedNotice = `Restart chrome-devtools-mcp to fully apply changes to --categoryExtensions, --headless from ${configFile.path}.`;
sinon.assert.calledOnceWithExactly(consoleError, expectedNotice);
sinon.assert.calledTwice(watched.logStream.write);
sinon.assert.calledWithMatch(
watched.logStream.write.firstCall,
`Applied config changes from ${configFile.path}\n`,
);
sinon.assert.calledWithMatch(
watched.logStream.write.secondCall,
`${expectedNotice}\n`,
);
});
it('keeps the previous configuration when reloading the config file fails', async () => {
using configFile = createTempFile(
JSON.stringify({memoryDebugging: true}),
'cd4a.watch.invalid.json',
);
await using watched = await watchTestServer(configFile.path);
const consoleError = sinon.stub(console, 'error');
await watched.updateConfigFile('[]');
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
sinon.assert.notCalled(watched.context.onConfigChanged);
const expectedError = `Keeping the previous configuration, failed to reload ${configFile.path}: Invalid JSON config file: Config must be a JSON object`;
sinon.assert.calledOnceWithExactly(consoleError, expectedError);
sinon.assert.calledOnceWithMatch(
watched.logStream.write,
`${expectedError}\n`,
);
});
it('keeps the previous configuration when reloaded options conflict with startup options', async () => {
using configFile = createTempFile(
JSON.stringify({browserUrl: 'http://127.0.0.1:9222'}),
'cd4a.watch.conflict.json',
);
await using watched = await watchTestServer(configFile.path);
const consoleError = sinon.stub(console, 'error');
await watched.updateConfigFile(JSON.stringify({categoryPwa: true}));
assert.strictEqual(watched.config.resolved.categoryPwa, undefined);
sinon.assert.notCalled(watched.context.onConfigChanged);
const expectedError = `Keeping the previous configuration, failed to reload ${configFile.path}: Arguments categoryPwa and browserUrl are mutually exclusive`;
sinon.assert.calledOnceWithExactly(consoleError, expectedError);
sinon.assert.calledOnceWithMatch(
watched.logStream.write,
`${expectedError}\n`,
);
});
it('does not log keeping the previous configuration when applying committed config fails', async () => {
using configFile = createTempFile(
JSON.stringify({memoryDebugging: false}),
'cd4a.watch.apply-error.json',
);
await using watched = await watchTestServer(configFile.path);
const applyError = new Error('failed to update pages');
watched.context.onConfigChanged.rejects(applyError);
const consoleError = sinon.stub(console, 'error');
await watched.updateConfigFile(JSON.stringify({memoryDebugging: true}));
assert.strictEqual(watched.config.resolved.memoryDebugging, true);
sinon.assert.calledOnceWithExactly(
consoleError,
`Failed to apply ${configFile.path}:`,
applyError,
);
sinon.assert.notCalled(watched.logStream.write);
});
});
});
async function getToolsWithFilteredCategories(
+1
View File
@@ -69,6 +69,7 @@ describe('cli args parsing', () => {
slim: false,
viaCli: false,
devtoolsComments: false,
watchConfig: false,
};
it('parses with default args', async () => {
+58
View File
@@ -350,4 +350,62 @@ describe('ServerConfig', () => {
assert.strictEqual(readonlyView.resolved.headless, true);
});
});
describe('getRestartRequiredChanges', () => {
it('ignores reloadable option changes', () => {
const previous = parseConfig();
const next = parseConfig([
'--memoryDebugging',
'--no-category-network',
'--slim',
]);
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), []);
});
it('returns restart-required options that changed', () => {
const previous = parseConfig();
const next = parseConfig([
'--headless',
'--categoryExtensions',
'--memoryDebugging',
]);
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), [
'categoryExtensions',
'headless',
]);
});
it('compares restart-required array and object options by value', () => {
const previous = parseConfig([
'--blockedUrlPattern=https://example.com/*',
]);
const same = parseConfig(['--blockedUrlPattern=https://example.com/*']);
const changed = parseConfig(['--blockedUrlPattern=https://other.com/*']);
assert.deepStrictEqual(previous.getRestartRequiredChanges(same), []);
assert.deepStrictEqual(previous.getRestartRequiredChanges(changed), [
'blockedUrlPattern',
]);
});
it('ignores restart-required options whose defaults are derived from other options', () => {
const previous = parseConfig(['--viaCli']);
const next = parseConfig([
'--viaCli',
'--browserUrl=http://127.0.0.1:9222',
]);
assert.strictEqual(previous.resolved.categoryExtensions, true);
assert.strictEqual(next.resolved.categoryExtensions, undefined);
assert.strictEqual(previous.resolved.isolated, true);
assert.strictEqual(next.resolved.isolated, false);
assert.strictEqual(previous.resolved.channel, 'stable');
assert.strictEqual(next.resolved.channel, undefined);
assert.deepStrictEqual(previous.getRestartRequiredChanges(next), [
'browserUrl',
]);
});
});
});