mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
c174677cf6
commit
98302f29a9
6 files changed
+126
-74
No files matched your search
+14
-12
@@ -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
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user