refactor(config): pass ReadonlyServerConfig to BrowserManager and add OptionGate (#3267)

- Passes live ReadonlyServerConfig to BrowserManager instead of a static
ParsedArguments snapshot so browser launches and connections read the
latest resolved configuration.
- Adds OptionGate and hasOptionChanged to FEATURE_CONTROLLERS in
McpContext so option-change callbacks (sourceMaps -> syncSourceMaps(),
experimentalIncludeAllPages -> createPagesSnapshot()) run through the
same controller table as capability-disable callbacks in
onConfigChanged.

---

<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-20 15:04:23 +00:00
1 parent c174677cf6
commit 98302f29a9
6 files changed
+126 -74

No files matched your search

+14 -12
View File
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {ParsedArguments} from './config/ConfigParser.js';
import type {ReadonlyServerConfig} from './config/ServerConfig.js';
import type {
Browser,
ChromeReleaseChannel,
@@ -40,16 +40,16 @@ export class BrowserManager {
#initPromise?: Promise<Browser>;
#mutex = new Mutex();
#closingCount = 0;
#serverArgs: ParsedArguments;
#config: ReadonlyServerConfig;
#options: BrowserManagerOptions;
#browserAttempt: BrowserAttempt = new BrowserAttempt();
constructor(
serverArgs: ParsedArguments,
config: ReadonlyServerConfig,
options: BrowserManagerOptions = {},
) {
this.#serverArgs = serverArgs;
this.#config = config;
this.#options = options;
}
@@ -198,9 +198,9 @@ export class BrowserManager {
async #initBrowser(): Promise<Browser> {
if (
this.#serverArgs.browserUrl ||
this.#serverArgs.wsEndpoint ||
this.#serverArgs.autoConnect
this.#config.resolved.browserUrl ||
this.#config.resolved.wsEndpoint ||
this.#config.resolved.autoConnect
) {
return await this.#connect();
}
@@ -222,14 +222,14 @@ export class BrowserManager {
allowedUrlPattern: allowlist,
ignoreDefaultChromeArg,
proxyServer,
} = this.#serverArgs;
} = this.#config.resolved;
const profileDirName =
channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = this.#serverArgs.userDataDir;
let userDataDir = this.#config.resolved.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(
os.homedir(),
@@ -242,7 +242,9 @@ export class BrowserManager {
});
}
const args: LaunchOptions['args'] = [...(this.#serverArgs.chromeArg ?? [])];
const args: LaunchOptions['args'] = [
...(this.#config.resolved.chromeArg ?? []),
];
if (proxyServer) {
args.push(`--proxy-server=${proxyServer}`);
}
@@ -346,9 +348,9 @@ export class BrowserManager {
blockedUrlPattern: blocklist,
allowedUrlPattern: allowlist,
categoryExtensions: enableExtensions,
} = this.#serverArgs;
} = this.#config.resolved;
// Important: only pass channel, if autoConnect is true.
const channel = isAutoConnect ? this.#serverArgs.channel : undefined;
const channel = isAutoConnect ? this.#config.resolved.channel : undefined;
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: BrowserManager.makeTargetFilter(enableExtensions),
+45 -15
View File
@@ -8,6 +8,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {isDeepStrictEqual} from 'node:util';
import {overrideDevToolsGlobals} from './devtools/DevtoolsUtils.js';
import type {
@@ -74,12 +75,39 @@ export interface McpContextOptions {
getBrowser: () => Promise<Browser>;
}
interface FeatureController {
gate: CapabilityGate;
onDisable(context: McpContext): Promise<void> | void;
interface OptionGate {
options: ReadonlyArray<keyof ParsedArguments>;
}
type FeatureController =
| {
gate: OptionGate;
onChange(context: McpContext): Promise<unknown> | void;
}
| {
gate: CapabilityGate;
onDisable(context: McpContext): Promise<void> | void;
};
function hasOptionChanged(
gate: OptionGate,
previousArgs: ParsedArguments,
nextArgs: ParsedArguments,
): boolean {
return gate.options.some(
option => !isDeepStrictEqual(nextArgs[option], previousArgs[option]),
);
}
const FEATURE_CONTROLLERS: readonly FeatureController[] = [
{
gate: {options: ['sourceMaps']},
onChange: context => context.syncSourceMaps(),
},
{
gate: {options: ['experimentalIncludeAllPages']},
onChange: context => context.createPagesSnapshot(),
},
{
gate: {category: ToolCategory.DEBUGGING},
onDisable: context => context.releaseScreencasts(),
@@ -199,19 +227,12 @@ export class McpContext implements Context, BrowserlessContext {
*/
async onConfigChanged(previousArgs: ParsedArguments): Promise<void> {
const nextArgs = this.#config.resolved;
if (nextArgs.sourceMaps !== previousArgs.sourceMaps) {
for (const mcpPage of this.#allMcpPages()) {
mcpPage.syncSourceMaps();
}
}
if (
nextArgs.experimentalIncludeAllPages !==
previousArgs.experimentalIncludeAllPages
) {
await this.createPagesSnapshot();
}
for (const controller of FEATURE_CONTROLLERS) {
if (
if ('onChange' in controller) {
if (hasOptionChanged(controller.gate, previousArgs, nextArgs)) {
await controller.onChange(this);
}
} else if (
isCapabilityEnabled(controller.gate, previousArgs) &&
!isCapabilityEnabled(controller.gate, nextArgs)
) {
@@ -220,6 +241,15 @@ export class McpContext implements Context, BrowserlessContext {
}
}
/**
* Synchronizes the DevTools source maps setting across all pages.
*/
syncSourceMaps(): void {
for (const mcpPage of this.#allMcpPages()) {
mcpPage.syncSourceMaps();
}
}
/**
* Stops and saves active screencast recordings across all pages when
* screencast tools are disabled.
+1 -2
View File
@@ -37,8 +37,7 @@ setupUnhandledRejectionHandler(() => {
});
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
// BrowserManager only reads non-reloadable launch and connection options.
const browserManager = new BrowserManager(args, {
const browserManager = new BrowserManager(serverConfig, {
logFile,
});
const serverPromise = McpServer.from(serverConfig, {
+1 -1
View File
@@ -144,7 +144,7 @@ async function setupMCPServer(): Promise<void> {
const serverConfig = configParser.parseConfig();
const args = serverConfig.resolved;
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
const browserManager = new BrowserManager(args, {
const browserManager = new BrowserManager(serverConfig, {
logFile,
});
mcpServer = await McpServer.from(serverConfig, {
+13 -17
View File
@@ -443,24 +443,20 @@ export async function createMcpServer(
logFile?: fs.WriteStream;
},
): Promise<{server: SdkMcpServer}> {
// BrowserManager only reads non-reloadable browser launch and connection
// options.
const browserManager = new BrowserManager(serverArgs, {
logFile: options.logFile,
});
// createMcpServer takes a flat ParsedArguments snapshot and only returns
// SdkMcpServer, so instances created here are never reloaded.
const server = await McpServer.from(
new ServerConfig({
cliArgs: {},
fileArgs: {},
configPath: undefined,
resolved: serverArgs,
}),
{
browserManager,
...options,
},
);
const serverConfig = new ServerConfig({
cliArgs: {},
fileArgs: {},
configPath: undefined,
resolved: serverArgs,
});
const browserManager = new BrowserManager(serverConfig, {
logFile: options.logFile,
});
const server = await McpServer.from(serverConfig, {
browserManager,
...options,
});
return {server: server.server};
}
+52 -27
View File
@@ -16,9 +16,9 @@ import {BrowserManager} from '../src/BrowserManager.js';
import {puppeteer, type Browser} from '../src/third_party/index.js';
import {
createMockParsedArguments,
createMockPuppeteerBrowser,
createMockPuppeteerTarget,
createMockServerConfig,
} from './mocks.js';
import {serverHooks} from './server.js';
@@ -75,7 +75,7 @@ describe('browser', () => {
'X-Test': 'visible',
};
const manager = new BrowserManager(
createMockParsedArguments({wsEndpoint: endpoint, wsHeaders: headers}),
createMockServerConfig({wsEndpoint: endpoint, wsHeaders: headers}),
);
await manager.ensureBrowser();
@@ -96,7 +96,7 @@ describe('browser', () => {
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
const connectStub = sinon.stub(puppeteer, 'connect');
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
channel: 'canary',
@@ -128,11 +128,36 @@ describe('browser', () => {
sinon.assert.notCalled(pptrBrowser.disconnect);
});
it('reads updated network restrictions from ServerConfig when launching', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
const config = createMockServerConfig({
headless: true,
isolated: true,
});
const manager = new BrowserManager(config);
config.commit(
createMockServerConfig({
headless: true,
isolated: true,
blockedUrlPattern: ['https://blocked.example.com/*'],
}),
);
await manager.ensureBrowser();
sinon.assert.calledOnceWithMatch(launchStub, {
blocklist: ['https://blocked.example.com/*'],
allowlist: undefined,
});
await manager.close();
});
it('does not pass a channel to Puppeteer when executablePath is set', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
const args = createMockParsedArguments({
const args = createMockServerConfig({
isolated: true,
channel: 'stable',
executablePath: '/path/to/chrome',
@@ -151,7 +176,7 @@ describe('browser', () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
const manager = new BrowserManager(
createMockParsedArguments({isolated: true}),
createMockServerConfig({isolated: true}),
);
await manager.ensureBrowser();
@@ -170,7 +195,7 @@ describe('browser', () => {
.stub(puppeteer, 'connect')
.resolves(pptrBrowser);
const args = createMockParsedArguments({
const args = createMockServerConfig({
browserUrl: 'http://127.0.0.1:9222',
channel: 'stable',
});
@@ -195,7 +220,7 @@ describe('browser', () => {
.stub(puppeteer, 'connect')
.resolves(createMockPuppeteerBrowser());
const args = createMockParsedArguments({
const args = createMockServerConfig({
wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/abc',
wsHeaders: {Authorization: 'Bearer token'},
});
@@ -216,7 +241,7 @@ describe('browser', () => {
.stub(puppeteer, 'connect')
.resolves(createMockPuppeteerBrowser());
const args = createMockParsedArguments({
const args = createMockServerConfig({
browserUrl: 'http://127.0.0.1:9222',
categoryExtensions,
});
@@ -236,7 +261,7 @@ describe('browser', () => {
const {promise, resolve} = Promise.withResolvers<Browser>();
const launchStub = sinon.stub(puppeteer, 'launch').returns(promise);
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
});
@@ -262,7 +287,7 @@ describe('browser', () => {
.onSecondCall()
.resolves(pptrBrowser);
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
});
@@ -288,7 +313,7 @@ describe('browser', () => {
.onSecondCall()
.resolves(pptrBrowser2);
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
});
@@ -318,7 +343,7 @@ describe('browser', () => {
browserClosed = true;
});
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
});
@@ -361,7 +386,7 @@ describe('browser', () => {
return closeDeferred.promise;
});
const args = createMockParsedArguments({
const args = createMockServerConfig({
headless: true,
isolated: true,
});
@@ -397,7 +422,7 @@ describe('browser', () => {
.resolves(secondBrowser);
const manager = new BrowserManager(
createMockParsedArguments({headless: true, isolated: true}),
createMockServerConfig({headless: true, isolated: true}),
);
const first = await manager.ensureBrowser();
@@ -428,7 +453,7 @@ describe('browser', () => {
.resolves(newBrowser);
const manager = new BrowserManager(
createMockParsedArguments({headless: true, isolated: true}),
createMockServerConfig({headless: true, isolated: true}),
);
const first = await manager.ensureBrowser();
@@ -456,7 +481,7 @@ describe('browser', () => {
.resolves(secondBrowser);
const manager = new BrowserManager(
createMockParsedArguments({browserUrl: 'http://127.0.0.1:9222'}),
createMockServerConfig({browserUrl: 'http://127.0.0.1:9222'}),
);
const first = await manager.ensureBrowser();
@@ -490,7 +515,7 @@ describe('browser', () => {
.resolves(freshBrowser);
const manager = new BrowserManager(
createMockParsedArguments({browserUrl: 'http://127.0.0.1:9222'}),
createMockServerConfig({browserUrl: 'http://127.0.0.1:9222'}),
);
const ensurePromise1 = manager.ensureBrowser();
@@ -528,7 +553,7 @@ describe('browser', () => {
.resolves(freshBrowser);
const manager = new BrowserManager(
createMockParsedArguments({headless: true, isolated: true}),
createMockServerConfig({headless: true, isolated: true}),
);
const ensurePromise1 = manager.ensureBrowser();
@@ -560,7 +585,7 @@ describe('browser', () => {
});
const manager = new BrowserManager(
createMockParsedArguments({headless: true, isolated: true}),
createMockServerConfig({headless: true, isolated: true}),
);
const ensurePromise1 = manager.ensureBrowser();
@@ -609,7 +634,7 @@ describe('browser', () => {
.resolves(secondBrowser);
const manager = new BrowserManager(
createMockParsedArguments({headless: true, isolated: true}),
createMockServerConfig({headless: true, isolated: true}),
);
const first = await manager.ensureBrowser();
@@ -705,7 +730,7 @@ describe('browser', () => {
`temp-folder-${crypto.randomUUID()}`,
);
const manager1 = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: false,
userDataDir: folderPath,
@@ -716,7 +741,7 @@ describe('browser', () => {
try {
try {
const manager2 = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: false,
userDataDir: folderPath,
@@ -747,7 +772,7 @@ describe('browser', () => {
`temp-folder-${crypto.randomUUID()}`,
);
const manager = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: false,
userDataDir: folderPath,
@@ -782,7 +807,7 @@ describe('browser', () => {
`temp-folder-${crypto.randomUUID()}`,
);
const launchManager = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: false,
userDataDir: folderPath,
@@ -793,7 +818,7 @@ describe('browser', () => {
const browser = await launchManager.ensureBrowser();
try {
const manager = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
userDataDir: folderPath,
autoConnect: true,
}),
@@ -823,7 +848,7 @@ describe('browser', () => {
);
const manager = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: true,
executablePath: await executablePath(),
@@ -868,7 +893,7 @@ describe('browser', () => {
);
const manager = new BrowserManager(
createMockParsedArguments({
createMockServerConfig({
headless: true,
isolated: true,
executablePath: await executablePath(),