Skip to content
Merged
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,14 @@ The Chrome DevTools MCP server supports the following configuration option:
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array

- **`--blockedUrlPattern`/ `--blocked-url-pattern`**
Restricts network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.
- **Type:** array

- **`--allowedUrlPattern`/ `--allowed-url-pattern`**
Restricts network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.
- **Type:** array

- **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array
Expand Down
66 changes: 66 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,71 @@ export class FakeIssuesManager extends DevTools.Common.ObjectWrapper
// DevTools CDP errors can get noisy.
DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;

// Stub out Network emulation commands on the DevTools Agent prototype globally.
// This prevents the DevTools Frontend from ever resetting/clearing Puppeteer's
// active network blocking/throttling rules during target setup or session lifetime.
const networkAgentPrototype =
DevTools.ProtocolClient.InspectorBackend.inspectorBackend.agentPrototypes.get(
'Network',
);
if (networkAgentPrototype) {
Object.defineProperty(
networkAgentPrototype,
'invoke_emulateNetworkConditionsByRule',
{
value: () => {
return Promise.resolve({
ruleIds: [],
getError: () => undefined,
});
},
writable: true,
configurable: true,
enumerable: true,
},
);
Object.defineProperty(networkAgentPrototype, 'invoke_overrideNetworkState', {
value: () => {
return Promise.resolve({
getError: () => undefined,
});
},
writable: true,
configurable: true,
enumerable: true,
});
Object.defineProperty(networkAgentPrototype, 'invoke_enable', {
value: () => {
return Promise.resolve({
getError: () => undefined,
});
},
writable: true,
configurable: true,
enumerable: true,
});
Object.defineProperty(networkAgentPrototype, 'invoke_disable', {
value: () => {
return Promise.resolve({
getError: () => undefined,
});
},
writable: true,
configurable: true,
enumerable: true,
});
Object.defineProperty(networkAgentPrototype, 'invoke_setBlockedURLs', {
value: () => {
return Promise.resolve({
getError: () => undefined,
});
},
writable: true,
configurable: true,
enumerable: true,
});
}

DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({
create: true,
data: {
Expand Down Expand Up @@ -146,6 +211,7 @@ const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
const connection = new PuppeteerDevToolsConnection(session);

const targetManager = universe.context.get(DevTools.TargetManager);

targetManager.observeModels(DevTools.DebuggerModel, SKIP_ALL_PAUSES);
targetManager.observeModels(
DevTools.NetworkManager.NetworkManager,
Expand Down
11 changes: 10 additions & 1 deletion src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ interface McpContextOptions {
experimentalIncludeAllPages?: boolean;
// Whether CrUX data should be fetched.
performanceCrux: boolean;
// Whether allowlist/blocklist is configured.
hasNetworkBlockOrAllowlist?: boolean;
}

const DEFAULT_TIMEOUT = 5_000;
Expand Down Expand Up @@ -345,7 +347,14 @@ export class McpContext implements Context {
const mcpPage = this.#getMcpPage(page);
const newSettings: EmulationSettings = {...mcpPage.emulationSettings};

if (!options.networkConditions) {
// Skip network emulation if blocklist/allowlist is configured, as it conflicts with blocking rules in Puppeteer.
if (this.#options.hasNetworkBlockOrAllowlist) {
if (options.networkConditions !== undefined) {
throw new Error(
'Network throttling is not supported when network blocking (allowlist/blocklist) is configured.',
);
}
} else if (!options.networkConditions) {
await page.emulateNetworkConditions(null);
delete newSettings.networkConditions;
} else if (options.networkConditions === 'Offline') {
Expand Down
12 changes: 12 additions & 0 deletions src/bin/chrome-devtools-mcp-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ export const cliOptions = {
describe:
'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
},
blockedUrlPattern: {
type: 'array',
describe:
'Restricts network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.',
conflicts: ['allowedUrlPattern'],
},
allowedUrlPattern: {
type: 'array',
describe:
'Restricts network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.',
conflicts: ['blockedUrlPattern'],
},
ignoreDefaultChromeArg: {
type: 'array',
describe:
Expand Down
8 changes: 8 additions & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export async function ensureBrowserConnected(options: {
channel?: Channel;
userDataDir?: string;
enableExtensions?: boolean;
blocklist?: string[];
allowlist?: string[];
}) {
const {channel, enableExtensions} = options;
if (browser?.connected) {
Expand All @@ -62,6 +64,8 @@ export async function ensureBrowserConnected(options: {
targetFilter: makeTargetFilter(enableExtensions),
defaultViewport: null,
handleDevToolsAsPage: true,
blocklist: options.blocklist,
allowlist: options.allowlist,
};

let autoConnect = false;
Expand Down Expand Up @@ -156,6 +160,8 @@ interface McpLaunchOptions {
devtools: boolean;
enableExtensions?: boolean;
viaCli?: boolean;
blocklist?: string[];
allowlist?: string[];
}

export function detectDisplay(): void {
Expand Down Expand Up @@ -235,6 +241,8 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions: options.enableExtensions,
blocklist: options.blocklist,
allowlist: options.allowlist,
});
if (options.logFile) {
// FIXME: we are probably subscribing too late to catch startup logs. We
Expand Down
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export async function createMcpServer(
chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`);
}
const devtools = serverArgs.experimentalDevtools ?? false;
const blocklist = serverArgs.blockedUrlPattern
? serverArgs.blockedUrlPattern.map(String)
: undefined;
const allowlist = serverArgs.allowedUrlPattern
? serverArgs.allowedUrlPattern.map(String)
: undefined;

const browser =
serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect
? await ensureBrowserConnected({
Expand All @@ -111,6 +118,8 @@ export async function createMcpServer(
: undefined,
userDataDir: serverArgs.userDataDir,
devtools,
blocklist,
allowlist,
})
: await ensureBrowserLaunched({
headless: serverArgs.headless,
Expand All @@ -126,13 +135,19 @@ export async function createMcpServer(
devtools,
enableExtensions: serverArgs.categoryExtensions,
viaCli: serverArgs.viaCli,
blocklist,
allowlist,
});

if (context?.browser !== browser) {
context = await McpContext.from(browser, logger, {
experimentalDevToolsDebugging: devtools,
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
performanceCrux: serverArgs.performanceCrux,
hasNetworkBlockOrAllowlist: Boolean(
(blocklist && blocklist.length > 0) ||
(allowlist && allowlist.length > 0),
),
});
await updateRoots();
}
Expand Down
8 changes: 8 additions & 0 deletions src/telemetry/flag_usage_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,5 +305,13 @@
{
"name": "memory_debugging",
"flagType": "boolean"
},
{
"name": "blocked_url_pattern_present",
"flagType": "boolean"
},
{
"name": "allowed_url_pattern_present",
"flagType": "boolean"
}
]
76 changes: 76 additions & 0 deletions tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {executablePath} from 'puppeteer';

import {detectDisplay, ensureBrowserConnected, launch} from '../src/browser.js';

import {serverHooks} from './server.js';

describe('browser', () => {
it('detects display does not crash', () => {
detectDisplay();
Expand Down Expand Up @@ -100,4 +102,78 @@ describe('browser', () => {
await browser.close();
}
});

describe('Blocking', () => {
const server = serverHooks();

it('blocks URLs in blocklist', async () => {
server.addHtmlRoute('/allowed.html', '<html><body>Allowed</body></html>');
server.addHtmlRoute('/blocked.html', '<html><body>Blocked</body></html>');

const browser = await launch({
headless: true,
isolated: true,
executablePath: await executablePath(),
devtools: false,
blocklist: ['*://*:*/blocked.html'],
});
try {
const page = await browser.newPage();

// Access allowed URL
await page.goto(server.getRoute('/allowed.html'));
const content = await page.evaluate(() => document.body.textContent);
assert.strictEqual(content, 'Allowed');

// Fetch of blocked URL from the page
const fetchSucceeded = await page.evaluate(async url => {
try {
await fetch(url);
return true;
} catch {
return false;
}
}, server.getRoute('/blocked.html'));

assert.strictEqual(fetchSucceeded, false);
} finally {
await browser.close();
}
});

it('blocks URLs not in allowlist', async () => {
server.addHtmlRoute('/allowed.html', '<html><body>Allowed</body></html>');
server.addHtmlRoute('/blocked.html', '<html><body>Blocked</body></html>');

const browser = await launch({
headless: true,
isolated: true,
executablePath: await executablePath(),
devtools: false,
allowlist: ['*://*:*/allowed.html'],
});
try {
const page = await browser.newPage();

// Access allowed URL
await page.goto(server.getRoute('/allowed.html'));
const content = await page.evaluate(() => document.body.textContent);
assert.strictEqual(content, 'Allowed');

// Fetch of blocked URL from the page
const fetchSucceeded = await page.evaluate(async url => {
try {
await fetch(url);
return true;
} catch {
return false;
}
}, server.getRoute('/blocked.html'));

assert.strictEqual(fetchSucceeded, false);
} finally {
await browser.close();
}
});
});
});
Loading
Loading