feat(config): share ReadonlyServerConfig with McpContext and McpPage (#3204)

Shares `ReadonlyServerConfig` across `McpServer`, `McpContext`, and
`McpPage` so page and context settings read from a single configuration
source.

- Passes `config: ReadonlyServerConfig` to `McpContext` and `McpPage`
instead of copying individual flags (`sourceMaps`, `allowlist`,
`blocklist`, `performanceCrux`, `experimentalDevToolsDebugging`,
`experimentalIncludeAllPages`, `allowUnrestrictedPaths`,
`categoryExtensions`, `hasNetworkBlockOrAllowlist`).
- Adds `McpPage.syncSourceMaps()` to update JS and CSS source map
settings on an initialized DevTools universe from
`config.resolved.sourceMaps`.
- Adds `McpPage.disposeCommentBridge()` and gates
`ensureDevToolsCommentBridge()` in `openDevTools()` and
`getDevToolsData()` on `config.resolved.devtoolsComments` so disabling
`--devtools-comments` tears down active bridges without `openDevTools()`
or `getDevToolsData()` re-attaching them.
- `McpPage.syncSourceMaps()` and `McpPage.disposeCommentBridge()` have
no production caller yet in this PR; they are called by
`McpContext.onConfigChanged()` in the follow-up config reload PR.
- Wires `configParser.parseConfig()` into `McpServer.from()` across the
CLI (`chrome-devtools-mcp-main.ts`, which no longer exports `args`) and
daemon (`daemon.ts`) entry points (while keeping
`createMcpServer(serverArgs, options)` accepting `ParsedArguments`).

<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 13:49:08 +00:00
1 parent 37f1fda81b
commit 0813bf3c51
11 files changed
+367 -140

No files matched your search

+21 -37
View File
@@ -40,6 +40,7 @@ import {
type Root,
type DevTools,
} from './third_party/index.js';
import type {ReadonlyServerConfig} from './config/ServerConfig.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {
BrowserlessContext,
@@ -53,28 +54,11 @@ import {McpWorker} from './McpWorker.js';
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
import {escapeForLog} from './utils/logger.js';
import {isAllowedUrl} from './utils/url.js';
export interface McpContextOptions {
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
experimentalDevToolsDebugging: boolean;
// Whether all page-like targets are exposed as pages.
experimentalIncludeAllPages?: boolean;
// Whether CrUX data should be fetched.
performanceCrux: boolean;
// Whether source maps are enabled in DevTools.
sourceMaps?: boolean;
// The allow list of URL patterns to allow loading resources.
allowlist?: string[];
// The block list of URL patterns to block loading resources.
blocklist?: string[];
// Whether to skip path validation when the client did not negotiate the roots
// capability. When false (default), tools that read or write local files are
// restricted to the OS temp directory. When true, the previous permissive
// behavior is restored.
allowUnrestrictedPaths?: boolean;
config: ReadonlyServerConfig;
// Custom navigation timeout in milliseconds to override default.
navigationTimeout?: number;
// Whether extension tools and targets are enabled.
categoryExtensions?: boolean;
// Shared heap snapshot manager to preserve loaded snapshots across browser reconnects.
heapSnapshotManager: HeapSnapshotManager;
// Returns the browser to use. Called by ensureBrowser(), so the browser
@@ -122,10 +106,10 @@ export class McpContext implements Context, BrowserlessContext {
#traceResults: TraceResult[] = [];
#locatorClass: typeof Locator;
#config: ReadonlyServerConfig;
#options: McpContextOptions;
#heapSnapshotManager: HeapSnapshotManager;
#roots: Root[] | undefined = undefined;
#allowUnrestrictedPaths: boolean;
private constructor(
logger: Logger,
@@ -140,9 +124,9 @@ export class McpContext implements Context, BrowserlessContext {
this.logger = logger;
this.#locatorClass = locatorClass;
this.#config = options.config;
this.#options = options;
this.#heapSnapshotManager = options.heapSnapshotManager;
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
}
/**
@@ -333,7 +317,10 @@ export class McpContext implements Context, BrowserlessContext {
// If the client never negotiated roots and the operator has explicitly
// opted into unrestricted access via --allow-unrestricted-paths, restore
// the previous permissive behavior and skip validation.
if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
if (
this.#roots === undefined &&
this.#config.resolved.allowUnrestrictedPaths
) {
// Canonical path might not exist yet so we fallback to
// path.resolve(filePath). Consumers should not follow symlinks.
return canonicalPath || path.resolve(filePath);
@@ -429,10 +416,6 @@ export class McpContext implements Context, BrowserlessContext {
await page.close();
}
get #hasNetworkBlockOrAllowlist(): boolean {
return !!(this.#options.allowlist || this.#options.blocklist);
}
async installPWA(options: InstallPWAOptions): Promise<string> {
try {
return await this.browser.installPWA(options);
@@ -478,7 +461,7 @@ export class McpContext implements Context, BrowserlessContext {
}
isCruxEnabled(): boolean {
return this.#options.performanceCrux;
return this.#config.resolved.performanceCrux;
}
getPages(): McpPage[] {
@@ -672,13 +655,12 @@ export class McpContext implements Context, BrowserlessContext {
(this.#selectedPage?.target === target ? this.#selectedPage : undefined);
if (!mcpPage) {
mcpPage = new McpPage(target, nextPageId++, {
config: this.#config,
locatorClass: this.#locatorClass,
hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
isolatedContextName: this.#getBrowserContextToNameMap().get(
target.browserContext(),
),
navigationTimeout: this.#options.navigationTimeout,
sourceMaps: this.#options.sourceMaps,
});
}
this.#mcpPages.set(target, mcpPage);
@@ -736,14 +718,14 @@ export class McpContext implements Context, BrowserlessContext {
(this.#selectedPage?.target === target ? this.#selectedPage : undefined);
const url = existingMcpPage ? existingMcpPage.url() : target.url();
if (
!this.#options.experimentalDevToolsDebugging &&
!this.#config.resolved.experimentalDevtools &&
url.startsWith('devtools://')
) {
return false;
}
if (
!isAllowedUrl(url, {
categoryExtensions: this.#options.categoryExtensions,
categoryExtensions: this.#config.resolved.categoryExtensions,
})
) {
return false;
@@ -753,13 +735,13 @@ export class McpContext implements Context, BrowserlessContext {
return true;
}
if (
this.#options.experimentalIncludeAllPages &&
this.#config.resolved.experimentalIncludeAllPages &&
(type === 'background_page' || type === 'webview')
) {
return true;
}
if (
this.#options.experimentalDevToolsDebugging &&
this.#config.resolved.experimentalDevtools &&
type === 'other' &&
url.startsWith('devtools://')
) {
@@ -995,10 +977,11 @@ export class McpContext implements Context, BrowserlessContext {
}
#validateUrlNotBlocked(url: URL): void {
if (!this.#options.blocklist) {
const blocklist = this.#config.resolved.blockedUrlPattern;
if (!blocklist) {
return;
}
for (const block of this.#options.blocklist) {
for (const block of blocklist) {
const pattern = new URLPattern(block);
if (pattern.test(url)) {
throw new Error(`Blocked by blocklist: ${url}`);
@@ -1007,10 +990,11 @@ export class McpContext implements Context, BrowserlessContext {
}
#validateUrlAllowed(url: URL): void {
if (!this.#options.allowlist) {
const allowlist = this.#config.resolved.allowedUrlPattern;
if (!allowlist) {
return;
}
for (const allow of this.#options.allowlist) {
for (const allow of allowlist) {
const pattern = new URLPattern(allow);
if (pattern.test(url)) {
return;
+37 -11
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {ReadonlyServerConfig} from './config/ServerConfig.js';
import {DevToolsCommentBridge} from './devtools/DevToolsCommentBridge.js';
import {
createTargetUniverse,
@@ -177,27 +178,24 @@ export class McpPage implements ContextPage {
#networkCollector?: NetworkCollector;
#consoleCollector?: ConsoleCollector;
#hasNetworkBlockOrAllowlist: boolean;
#locatorClass: typeof Locator;
#navigationTimeout: number;
#sourceMaps: boolean;
#config: ReadonlyServerConfig;
#commentBridge?: DevToolsCommentBridge;
constructor(
target: Target,
id: number,
options: {
hasNetworkBlockOrAllowlist: boolean;
config: ReadonlyServerConfig;
locatorClass: typeof Locator;
isolatedContextName?: string;
navigationTimeout?: number;
sourceMaps?: boolean;
},
) {
this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist;
this.#config = options.config;
this.#locatorClass = options.locatorClass;
this.#navigationTimeout = options.navigationTimeout ?? NAVIGATION_TIMEOUT;
this.#sourceMaps = options.sourceMaps ?? true;
this.target = target;
this.id = id;
this.isolatedContextName = options.isolatedContextName;
@@ -206,6 +204,13 @@ export class McpPage implements ContextPage {
};
}
get #hasNetworkBlockOrAllowlist(): boolean {
return Boolean(
this.#config.resolved.allowedUrlPattern ||
this.#config.resolved.blockedUrlPattern,
);
}
get pptrPage(): Page {
if (!this.#pptrPage) {
throw new Error(
@@ -351,13 +356,26 @@ export class McpPage implements ContextPage {
try {
const session = await this.pptrPage.createCDPSession();
this.#devtoolsUniverse = await createTargetUniverse(session, {
sourceMaps: this.#sourceMaps,
sourceMaps: this.#config.resolved.sourceMaps,
});
} catch (e) {
logger?.('Failed to initialize DevTools universe', e);
}
}
syncSourceMaps(): void {
if (!this.#devtoolsUniverse) {
return;
}
const sourceMaps = this.#config.resolved.sourceMaps;
this.#devtoolsUniverse.universe.settings
.resolve(DevTools.SDKSettings.jsSourceMapsEnabledSettingDescriptor)
.set(sourceMaps);
this.#devtoolsUniverse.universe.settings
.resolve(DevTools.SDKSettings.cssSourceMapsEnabledSettingDescriptor)
.set(sourceMaps);
}
get devtoolsUniverse(): TargetUniverse | undefined {
return this.#devtoolsUniverse;
}
@@ -548,6 +566,11 @@ export class McpPage implements ContextPage {
return this.#commentBridge;
}
disposeCommentBridge(): void {
this.#commentBridge?.dispose();
this.#commentBridge = undefined;
}
/**
* Returns the comment bridge attached to the DevTools page, or undefined if
* the DevTools page does not support DevTools comments.
@@ -578,7 +601,9 @@ export class McpPage implements ContextPage {
async openDevTools(): Promise<Page | undefined> {
const devtoolsPage = await this.pptrPage.openDevTools();
await this.ensureDevToolsCommentBridge(devtoolsPage);
if (this.#config.resolved.devtoolsComments) {
await this.ensureDevToolsCommentBridge(devtoolsPage);
}
return devtoolsPage;
}
@@ -661,8 +686,7 @@ export class McpPage implements ContextPage {
dispose(): void {
this.#disposed = true;
this.#commentBridge?.dispose();
this.#commentBridge = undefined;
this.disposeCommentBridge();
if (this.#screenRecorder) {
const {recorder} = this.#screenRecorder;
this.#screenRecorder = null;
@@ -1074,7 +1098,9 @@ export class McpPage implements ContextPage {
logger?.('No DevTools page detected');
return {};
}
await this.ensureDevToolsCommentBridge(devtoolsPage);
if (this.#config.resolved.devtoolsComments) {
await this.ensureDevToolsCommentBridge(devtoolsPage);
}
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
async () => {
// @ts-expect-error no types
+4 -2
View File
@@ -27,7 +27,8 @@ await checkForUpdates(
);
const configParser = new ConfigParser(VERSION);
export const args = configParser.parse();
const serverConfig = configParser.parseConfig();
const args = serverConfig.resolved;
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
@@ -36,10 +37,11 @@ setupUnhandledRejectionHandler(() => {
});
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
// BrowserManager only reads non-reloadable launch and connection options.
const browserManager = new BrowserManager(args, {
logFile,
});
const serverPromise = McpServer.from(args, {
const serverPromise = McpServer.from(serverConfig, {
browserManager,
});
+3 -2
View File
@@ -141,12 +141,13 @@ let server: Server | null = null;
async function setupMCPServer(): Promise<void> {
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
const configParser = new ConfigParser(VERSION);
const args = configParser.parse();
const serverConfig = configParser.parseConfig();
const args = serverConfig.resolved;
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
const browserManager = new BrowserManager(args, {
logFile,
});
mcpServer = await McpServer.from(args, {
mcpServer = await McpServer.from(serverConfig, {
browserManager,
logFile,
});
+41 -34
View File
@@ -11,6 +11,7 @@ import {pathToFileURL} from 'node:url';
import {BrowserManager} from './BrowserManager.js';
import {type ParsedArguments} from './config/ConfigParser.js';
import {DEFAULT_FILESYSTEM_ROOT} from './config/mcp-options.js';
import {ServerConfig} from './config/ServerConfig.js';
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
import {McpContext} from './McpContext.js';
import {HeapSnapshotManager} from './processors/HeapSnapshotManager.js';
@@ -55,7 +56,7 @@ interface ToolEntry {
export class McpServer {
readonly server: SdkMcpServer;
#serverArgs: ParsedArguments;
#config: ServerConfig;
#browserManager: BrowserManager;
#context: McpContext;
#heapSnapshotManager = new HeapSnapshotManager();
@@ -63,7 +64,7 @@ export class McpServer {
/**
* Client roots stay valid across browser reconnects and only the client can
* invalidate them through a `roots/list_changed` notification. CLI-configured
* roots are read from `#serverArgs` when combining roots.
* roots are read from `#config` when combining roots.
*/
#lastClientRoots?: Root[];
/**
@@ -74,19 +75,19 @@ export class McpServer {
#toolMutex = new Mutex();
#tools = new Map<string, ToolEntry>();
private constructor(serverArgs: ParsedArguments, options: McpServerOptions) {
this.#serverArgs = serverArgs;
private constructor(config: ServerConfig, options: McpServerOptions) {
this.#config = config;
this.#browserManager = options.browserManager;
const serverArgs = this.#config.resolved;
if (this.#serverArgs.usageStatistics) {
if (serverArgs.usageStatistics) {
ClearcutLogger.initialize({
persistence: new FilePersistence(),
logFile: this.#serverArgs.logFile,
logFile: serverArgs.logFile,
appVersion: VERSION,
clearcutEndpoint: this.#serverArgs.clearcutEndpoint,
clearcutForceFlushIntervalMs:
this.#serverArgs.clearcutForceFlushIntervalMs,
clearcutIncludePidHeader: this.#serverArgs.clearcutIncludePidHeader,
clearcutEndpoint: serverArgs.clearcutEndpoint,
clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs,
clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader,
});
}
@@ -104,15 +105,7 @@ export class McpServer {
);
this.#context = McpContext.create(logger, {
experimentalDevToolsDebugging:
this.#serverArgs.experimentalDevtools ?? false,
experimentalIncludeAllPages: this.#serverArgs.experimentalIncludeAllPages,
performanceCrux: this.#serverArgs.performanceCrux,
sourceMaps: this.#serverArgs.sourceMaps,
allowlist: this.#serverArgs.allowedUrlPattern,
blocklist: this.#serverArgs.blockedUrlPattern,
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
categoryExtensions: this.#serverArgs.categoryExtensions,
config: this.#config,
heapSnapshotManager: this.#heapSnapshotManager,
getBrowser: () => this.#browserManager.ensureBrowser(),
});
@@ -136,9 +129,9 @@ export class McpServer {
},
);
} else if (
!this.#serverArgs.allowUnrestrictedPaths &&
((this.#serverArgs.filesystemRoot ?? []).length === 0 ||
this.#serverArgs.filesystemRoot === DEFAULT_FILESYSTEM_ROOT)
!this.#config.resolved.allowUnrestrictedPaths &&
((this.#config.resolved.filesystemRoot ?? []).length === 0 ||
this.#config.resolved.filesystemRoot === DEFAULT_FILESYSTEM_ROOT)
) {
console.warn(
'[chrome-devtools-mcp] The connecting client did not negotiate the MCP roots ' +
@@ -218,26 +211,26 @@ export class McpServer {
}
static async from(
serverArgs: ParsedArguments,
config: ServerConfig,
options: McpServerOptions,
): Promise<McpServer> {
const server = new McpServer(serverArgs, options);
const server = new McpServer(config, options);
await server.#init();
return server;
}
async #init(): Promise<void> {
for (const tool of createTools(this.#serverArgs)) {
// Tool definitions take a ParsedArguments snapshot; reload recreates them.
for (const tool of createTools(this.#config.resolved)) {
this.#registerTool(tool);
}
await loadIssueDescriptions();
}
#combinedRoots(): Root[] | undefined {
const serverArgs = this.#config.resolved;
const configuredRoots = (
this.#serverArgs.allowUnrestrictedPaths
? []
: (this.#serverArgs.filesystemRoot ?? [])
serverArgs.allowUnrestrictedPaths ? [] : (serverArgs.filesystemRoot ?? [])
).map(root => {
const rootPath = path.resolve(root);
return {
@@ -284,9 +277,11 @@ export class McpServer {
}
#createToolHandler(tool: AnyToolDefinition): ToolHandler {
// No production code calls ServerConfig.commit() yet; a follow-up PR passes
// ReadonlyServerConfig to ToolHandler.
return new ToolHandler(
tool,
this.#serverArgs,
this.#config.resolved,
() => this.#getContext(),
this.#toolMutex,
() => this.#browserManager.abandonPendingAttempt(),
@@ -319,7 +314,7 @@ export class McpServer {
*
* Maintained as a public API for backwards compatibility because external
* consumers and integrations rely on `createMcpServer()`. For new code,
* prefer using `McpServer.from(serverArgs, options)`.
* prefer using `McpServer.from(config, options)`.
*/
export async function createMcpServer(
serverArgs: ParsedArguments,
@@ -327,12 +322,24 @@ 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,
});
const server = await McpServer.from(serverArgs, {
browserManager,
...options,
});
// 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,
},
);
return {server: server.server};
}
+19 -13
View File
@@ -36,6 +36,7 @@ import {
createMockPuppeteerBrowser,
createMockPuppeteerPage,
createMockPuppeteerTarget,
createMockServerConfig,
} from './mocks.js';
import {serverHooks, TestServer} from './server.js';
import {
@@ -80,8 +81,7 @@ describe('McpContext', () => {
return await createConnectedMcpContext(
browser,
{
experimentalDevToolsDebugging: false,
performanceCrux: true,
config: createMockServerConfig(),
heapSnapshotManager: new HeapSnapshotManager(),
},
Locator,
@@ -95,8 +95,9 @@ describe('McpContext', () => {
const context = await createConnectedMcpContext(
browser,
{
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
}),
heapSnapshotManager: new HeapSnapshotManager(),
},
Locator,
@@ -141,8 +142,9 @@ describe('McpContext', () => {
const context = await createConnectedMcpContext(
browser,
{
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
}),
heapSnapshotManager: new HeapSnapshotManager(),
},
Locator,
@@ -603,8 +605,9 @@ describe('McpContext', () => {
it('continues page ids across contexts so stale ids do not resolve', async () => {
await withBrowser(async browser => {
const options = {
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
}),
heapSnapshotManager: new HeapSnapshotManager(),
};
const first = await createConnectedMcpContext(browser, options, Locator);
@@ -648,8 +651,9 @@ describe('McpContext', () => {
return McpContext.create(
undefined,
{
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
}),
heapSnapshotManager: new HeapSnapshotManager(),
getBrowser,
},
@@ -1169,10 +1173,12 @@ describe('McpContext', () => {
return await createConnectedMcpContext(
browser,
{
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
allowedUrlPattern: options.allowlist,
blockedUrlPattern: options.blocklist,
}),
heapSnapshotManager: new HeapSnapshotManager(),
...options,
},
Locator,
);
+194 -22
View File
@@ -9,6 +9,7 @@ import {describe, it} from 'node:test';
import sinon from 'sinon';
import type {ReadonlyServerConfig} from '../src/config/ServerConfig.js';
import type {TargetUniverse} from '../src/devtools/DevtoolsUtils.js';
import {McpPage, replaceHtmlElementsWithUids} from '../src/McpPage.js';
import {DevTools, Locator, ScreenRecorder} from '../src/third_party/index.js';
@@ -16,10 +17,12 @@ import type {JSONSchema7Definition} from '../src/third_party/index.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import type {TextSnapshotNode} from '../src/types.js';
import {
createMockCDPSession,
createMockDialog,
createMockMcpResponse,
createMockPuppeteerPage,
createMockPuppeteerTarget,
createMockServerConfig,
createMockWebMCPTool,
} from './mocks.js';
import {serverHooks} from './server.js';
@@ -274,14 +277,12 @@ describe('replaceHtmlElementsWithUids', () => {
});
describe('McpPage', () => {
async function createMcpPage(
options: {hasNetworkBlockOrAllowlist?: boolean} = {},
) {
async function createMcpPage(options: {config?: ReadonlyServerConfig} = {}) {
const pptrPage = createMockPuppeteerPage();
pptrPage.emulateFocusedPage.resolves();
const target = createMockPuppeteerTarget({page: pptrPage});
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: options.hasNetworkBlockOrAllowlist ?? false,
config: options.config ?? createMockServerConfig(),
locatorClass: Locator,
});
const mockSession = {
@@ -317,7 +318,7 @@ describe('McpPage', () => {
title: 'Target Title',
});
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
@@ -347,7 +348,7 @@ describe('McpPage', () => {
target.page.resolves(null);
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
@@ -361,7 +362,7 @@ describe('McpPage', () => {
it('disposes safely before init() is called and rejects subsequent init()', async () => {
const target = createMockPuppeteerTarget();
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
@@ -379,7 +380,7 @@ describe('McpPage', () => {
const pptrPage = createMockPuppeteerPage();
const target = createMockPuppeteerTarget({page: pptrPage});
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
@@ -395,7 +396,7 @@ describe('McpPage', () => {
it('stops and clears an active screenRecorder on dispose()', async () => {
const target = createMockPuppeteerTarget();
const mcpPage = new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
const recorder = sinon.createStubInstance(ScreenRecorder);
@@ -413,7 +414,7 @@ describe('McpPage', () => {
function createPage(url: string, title: string): McpPage {
const target = createMockPuppeteerTarget({url, title});
return new McpPage(target, 1, {
hasNetworkBlockOrAllowlist: false,
config: createMockServerConfig(),
locatorClass: Locator,
});
}
@@ -513,7 +514,9 @@ describe('McpPage', () => {
it('throws when networkConditions is set with network blocking enabled', async () => {
const {mcpPage, pptrPage} = await createMcpPage({
hasNetworkBlockOrAllowlist: true,
config: createMockServerConfig({
blockedUrlPattern: ['*://blocked.example.com/*'],
}),
});
await assert.rejects(
() => mcpPage.emulate({networkConditions: 'Slow 3G'}),
@@ -1010,7 +1013,9 @@ describe('McpPage', () => {
describe('DevToolsCommentBridge lifecycle', () => {
it('does not create commentBridge on construction or getDevToolsPage', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
pptrPage.hasDevTools.resolves(true);
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
@@ -1023,7 +1028,9 @@ describe('McpPage', () => {
});
it('creates and attaches commentBridge when openDevTools is called', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
const devtoolsPage = createMockPuppeteerPage();
devtoolsPage.evaluate.resolves(true);
pptrPage.openDevTools.resolves(devtoolsPage);
@@ -1036,6 +1043,26 @@ describe('McpPage', () => {
sinon.assert.calledOnce(devtoolsPage.exposeFunction);
});
it('does not attach commentBridge in openDevTools() when devtoolsComments is disabled', async () => {
const config = createMockServerConfig({devtoolsComments: true});
const {mcpPage, pptrPage} = await createMcpPage({config});
const devtoolsPage = createMockPuppeteerPage();
devtoolsPage.evaluate.resolves(true);
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
assert.notStrictEqual(mcpPage.commentBridge, undefined);
config.commit(createMockServerConfig());
mcpPage.disposeCommentBridge();
devtoolsPage.exposeFunction.resetHistory();
const result = await mcpPage.openDevTools();
assert.strictEqual(result, devtoolsPage);
assert.strictEqual(mcpPage.commentBridge, undefined);
sinon.assert.notCalled(devtoolsPage.exposeFunction);
});
it('returns the commentBridge when the DevTools page has a CD4A bridge', async () => {
const {mcpPage} = await createMcpPage();
const devtoolsPage = createMockPuppeteerPage();
@@ -1059,25 +1086,27 @@ describe('McpPage', () => {
});
it('disposes commentBridge on mcpPage.dispose()', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
const bridge = mcpPage.commentBridge;
assert.notStrictEqual(bridge, undefined);
assert.ok(bridge);
if (bridge) {
const disposeSpy = sinon.spy(bridge, 'dispose');
mcpPage.dispose();
const disposeSpy = sinon.spy(bridge, 'dispose');
mcpPage.dispose();
sinon.assert.calledOnce(disposeSpy);
assert.strictEqual(mcpPage.commentBridge, undefined);
}
sinon.assert.calledOnce(disposeSpy);
assert.strictEqual(mcpPage.commentBridge, undefined);
});
it('creates and attaches commentBridge when getDevToolsData is called', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
pptrPage.hasDevTools.resolves(true);
const devtoolsPage = createMockPuppeteerPage();
devtoolsPage.evaluate.resolves(true);
@@ -1098,6 +1127,149 @@ describe('McpPage', () => {
sinon.assert.calledOnce(devtoolsPage.exposeFunction);
sinon.assert.calledThrice(devtoolsPage.evaluate);
});
it('disposes and clears commentBridge on mcpPage.disposeCommentBridge()', async () => {
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
const bridge = mcpPage.commentBridge;
assert.ok(bridge);
const disposeSpy = sinon.spy(bridge, 'dispose');
mcpPage.disposeCommentBridge();
sinon.assert.calledOnceWithExactly(disposeSpy);
assert.strictEqual(mcpPage.commentBridge, undefined);
});
it('does not re-attach commentBridge in getDevToolsData() when devtoolsComments is disabled', async () => {
const config = createMockServerConfig({devtoolsComments: true});
const {mcpPage, pptrPage} = await createMcpPage({config});
pptrPage.hasDevTools.resolves(true);
const devtoolsPage = createMockPuppeteerPage();
devtoolsPage.evaluate.resolves(true);
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
assert.notStrictEqual(mcpPage.commentBridge, undefined);
config.commit(createMockServerConfig());
mcpPage.disposeCommentBridge();
assert.strictEqual(mcpPage.commentBridge, undefined);
devtoolsPage.exposeFunction.resetHistory();
devtoolsPage.evaluate.reset();
devtoolsPage.evaluate.resolves({
cdpRequestId: 'req-2',
cdpBackendNodeId: 20,
});
const data = await mcpPage.getDevToolsData();
assert.deepStrictEqual(data, {
cdpRequestId: 'req-2',
cdpBackendNodeId: 20,
});
assert.strictEqual(mcpPage.commentBridge, undefined);
sinon.assert.notCalled(devtoolsPage.exposeFunction);
sinon.assert.calledOnce(devtoolsPage.evaluate);
});
it('re-attaches commentBridge when ensureDevToolsCommentBridge() is called after disposeCommentBridge()', async () => {
const {mcpPage, pptrPage} = await createMcpPage({
config: createMockServerConfig({devtoolsComments: true}),
});
const devtoolsPage = createMockPuppeteerPage();
devtoolsPage.evaluate.resolves(true);
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
mcpPage.disposeCommentBridge();
assert.strictEqual(mcpPage.commentBridge, undefined);
const bridge = await mcpPage.ensureDevToolsCommentBridge(devtoolsPage);
assert.notStrictEqual(bridge, undefined);
assert.strictEqual(bridge, mcpPage.commentBridge);
});
});
describe('syncSourceMaps()', () => {
it('does nothing before init() initializes the DevTools universe', () => {
const config = createMockServerConfig({sourceMaps: false});
const target = createMockPuppeteerTarget();
const mcpPage = new McpPage(target, 1, {
config,
locatorClass: Locator,
});
mcpPage.syncSourceMaps();
assert.strictEqual(mcpPage.devtoolsUniverse, undefined);
sinon.assert.notCalled(target.page);
});
it('updates JS and CSS source map settings on an initialized DevTools universe', async () => {
const config = createMockServerConfig();
const pptrPage = createMockPuppeteerPage();
pptrPage.createCDPSession.resolves(createMockCDPSession());
const target = createMockPuppeteerTarget({page: pptrPage});
const mcpPage = new McpPage(target, 1, {
config,
locatorClass: Locator,
});
await mcpPage.init();
config.commit(createMockServerConfig({sourceMaps: false}));
mcpPage.syncSourceMaps();
const settings = mcpPage.devtoolsUniverse?.universe.settings;
assert.ok(settings);
assert.strictEqual(
settings
.resolve(DevTools.SDKSettings.jsSourceMapsEnabledSettingDescriptor)
.get(),
false,
);
assert.strictEqual(
settings
.resolve(DevTools.SDKSettings.cssSourceMapsEnabledSettingDescriptor)
.get(),
false,
);
});
it('reads updated sourceMaps from config when initialized later', async () => {
const config = createMockServerConfig();
const pptrPage = createMockPuppeteerPage();
pptrPage.createCDPSession.resolves(createMockCDPSession());
const target = createMockPuppeteerTarget({page: pptrPage});
const mcpPage = new McpPage(target, 1, {
config,
locatorClass: Locator,
});
config.commit(createMockServerConfig({sourceMaps: false}));
await mcpPage.init();
const settings = mcpPage.devtoolsUniverse?.universe.settings;
assert.ok(settings);
assert.strictEqual(
settings
.resolve(DevTools.SDKSettings.jsSourceMapsEnabledSettingDescriptor)
.get(),
false,
);
assert.strictEqual(
settings
.resolve(DevTools.SDKSettings.cssSourceMapsEnabledSettingDescriptor)
.get(),
false,
);
});
});
describe('resolveBackendNodeId()', () => {
+8 -8
View File
@@ -30,6 +30,12 @@ describe('McpServer', () => {
ClearcutLogger.resetForTesting();
});
function parseConfig(argv: string[] = []) {
return new ConfigParser('1.0.0', ['node', 'script.js', ...argv], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parseConfig();
}
async function createTestServer(
extraArgs: string[] = [],
{mockContext = true}: {mockContext?: boolean} = {},
@@ -45,14 +51,8 @@ describe('McpServer', () => {
sinon.stub(McpContext, 'create').returns(context);
}
const serverArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', ...extraArgs],
{
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
},
).parse();
const server = await McpServer.from(serverArgs, {browserManager});
const config = parseConfig(extraArgs);
const server = await McpServer.from(config, {browserManager});
return {server, browserManager, context};
}
+23 -1
View File
@@ -1304,6 +1304,16 @@ export function createMockContextAnalysisResult(): DevTools.HeapSnapshotModel.He
};
}
export function stripUndefined<T extends object>(options: T): T {
const result = {...options};
for (const [key, value] of Object.entries(result)) {
if (value === undefined) {
Reflect.deleteProperty(result, key);
}
}
return result;
}
export function createMockParsedArguments(
options: Partial<ParsedArguments> = {},
): ParsedArguments {
@@ -1311,7 +1321,19 @@ export function createMockParsedArguments(
{},
{suppressUsageStatisticsNotice: true},
);
return {...defaultArgs, ...options};
return {...defaultArgs, ...stripUndefined(options)};
}
export function createMockServerConfig(
options: Partial<ParsedArguments> = {},
): ServerConfig {
const definedOptions = stripUndefined(options);
return new ServerConfig({
cliArgs: definedOptions,
fileArgs: {},
configPath: definedOptions.config,
resolved: createMockParsedArguments(definedOptions),
});
}
export function createMockHeapSnapshotNode(
+4 -3
View File
@@ -18,7 +18,7 @@ import {HeapSnapshotManager} from '../src/processors/HeapSnapshotManager.js';
import {resolveCanonicalPath} from '../src/utils/files.js';
import {escapeForLog} from '../src/utils/logger.js';
import {createMockPuppeteerBrowser} from './mocks.js';
import {createMockPuppeteerBrowser, createMockServerConfig} from './mocks.js';
import {
createConnectedMcpContext,
createTempDir,
@@ -173,8 +173,9 @@ describe('McpContext path validation escaping', () => {
const browser = createMockPuppeteerBrowser();
browser.targets.returns([]);
return await createConnectedMcpContext(browser, {
experimentalDevToolsDebugging: false,
performanceCrux: false,
config: createMockServerConfig({
performanceCrux: false,
}),
heapSnapshotManager: new HeapSnapshotManager(),
});
}
+13 -7
View File
@@ -41,7 +41,9 @@ import {stableIdSymbol} from '../src/utils/id.js';
import {
createMockParsedArguments,
createMockPuppeteerPage,
createMockServerConfig,
mockListener,
stripUndefined,
} from './mocks.js';
export function assertNoServiceWorkerReported(targets: Target[], id: string) {
@@ -212,6 +214,16 @@ export async function withMcpContext(
McpContext.resetPageIdsForTesting();
McpContext.resetWorkerIdsForTesting();
const parsedArgs = createMockParsedArguments(args);
const serverConfig = createMockServerConfig({
...args,
...stripUndefined({
performanceCrux: options.performanceCrux,
sourceMaps: options.sourceMaps,
allowedUrlPattern: options.allowedUrlPattern,
blockedUrlPattern: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths,
}),
});
const response = new McpResponse(parsedArgs);
const heapSnapshotManager = new HeapSnapshotManager();
if (context) {
@@ -220,16 +232,10 @@ export async function withMcpContext(
context = await createConnectedMcpContext(
browser,
{
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
sourceMaps: options.sourceMaps ?? true,
allowlist: options.allowedUrlPattern,
blocklist: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
config: serverConfig,
navigationTimeout:
options.navigationTimeout ??
(process.platform === 'win32' ? 20000 : undefined),
categoryExtensions: parsedArgs.categoryExtensions,
heapSnapshotManager,
},
Locator,