diff --git a/README.md b/README.md index cfe7dd3ea..ea3f4e962 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index a0e043eff..c09a1d87d 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -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: { @@ -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, diff --git a/src/McpContext.ts b/src/McpContext.ts index 3e7e410a8..4346070d2 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -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; @@ -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') { diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index b65929e12..0cfb3c2b8 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -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: diff --git a/src/browser.ts b/src/browser.ts index fbdf3ed6b..6b077ad23 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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) { @@ -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; @@ -156,6 +160,8 @@ interface McpLaunchOptions { devtools: boolean; enableExtensions?: boolean; viaCli?: boolean; + blocklist?: string[]; + allowlist?: string[]; } export function detectDisplay(): void { @@ -235,6 +241,8 @@ export async function launch(options: McpLaunchOptions): Promise { 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 diff --git a/src/index.ts b/src/index.ts index fdd5790c1..fe725af62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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({ @@ -111,6 +118,8 @@ export async function createMcpServer( : undefined, userDataDir: serverArgs.userDataDir, devtools, + blocklist, + allowlist, }) : await ensureBrowserLaunched({ headless: serverArgs.headless, @@ -126,6 +135,8 @@ export async function createMcpServer( devtools, enableExtensions: serverArgs.categoryExtensions, viaCli: serverArgs.viaCli, + blocklist, + allowlist, }); if (context?.browser !== browser) { @@ -133,6 +144,10 @@ export async function createMcpServer( experimentalDevToolsDebugging: devtools, experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, performanceCrux: serverArgs.performanceCrux, + hasNetworkBlockOrAllowlist: Boolean( + (blocklist && blocklist.length > 0) || + (allowlist && allowlist.length > 0), + ), }); await updateRoots(); } diff --git a/src/telemetry/flag_usage_metrics.json b/src/telemetry/flag_usage_metrics.json index 941baad2e..a273b5ebe 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -305,5 +305,13 @@ { "name": "memory_debugging", "flagType": "boolean" + }, + { + "name": "blocked_url_pattern_present", + "flagType": "boolean" + }, + { + "name": "allowed_url_pattern_present", + "flagType": "boolean" } ] diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 85e9c592f..3c4ed5976 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -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(); @@ -100,4 +102,78 @@ describe('browser', () => { await browser.close(); } }); + + describe('Blocking', () => { + const server = serverHooks(); + + it('blocks URLs in blocklist', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + 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', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + 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(); + } + }); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 05f2d3b06..25f07d18e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -345,4 +345,94 @@ describe('cli args parsing', () => { ); assert.strictEqual(disabledArgs.performanceCrux, false); }); + + it('parses blocked-url-pattern flags as array', async () => { + const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); + assert.strictEqual(defaultArgs.blockedUrlPattern, undefined); + + const singleArgs = parseArguments( + '1.0.0', + ['node', 'main.js', '--blocked-url-pattern=https://example.com/*'], + {}, + ); + assert.deepStrictEqual(singleArgs.blockedUrlPattern, [ + 'https://example.com/*', + ]); + + const repeatedArgs = parseArguments( + '1.0.0', + [ + 'node', + 'main.js', + '--blocked-url-pattern=https://a.com/*', + '--blocked-url-pattern=https://b.com/*', + ], + {}, + ); + assert.deepStrictEqual(repeatedArgs.blockedUrlPattern, [ + 'https://a.com/*', + 'https://b.com/*', + ]); + + const spaceSeparatedArgs = parseArguments( + '1.0.0', + [ + 'node', + 'main.js', + '--blocked-url-pattern', + 'https://a.com/*', + 'https://b.com/*', + ], + {}, + ); + assert.deepStrictEqual(spaceSeparatedArgs.blockedUrlPattern, [ + 'https://a.com/*', + 'https://b.com/*', + ]); + }); + + it('parses allowed-url-pattern flags as array', async () => { + const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); + assert.strictEqual(defaultArgs.allowedUrlPattern, undefined); + + const singleArgs = parseArguments( + '1.0.0', + ['node', 'main.js', '--allowed-url-pattern=https://example.com/*'], + {}, + ); + assert.deepStrictEqual(singleArgs.allowedUrlPattern, [ + 'https://example.com/*', + ]); + + const repeatedArgs = parseArguments( + '1.0.0', + [ + 'node', + 'main.js', + '--allowed-url-pattern=https://a.com/*', + '--allowed-url-pattern=https://b.com/*', + ], + {}, + ); + assert.deepStrictEqual(repeatedArgs.allowedUrlPattern, [ + 'https://a.com/*', + 'https://b.com/*', + ]); + + const spaceSeparatedArgs = parseArguments( + '1.0.0', + [ + 'node', + 'main.js', + '--allowed-url-pattern', + 'https://a.com/*', + 'https://b.com/*', + ], + {}, + ); + assert.deepStrictEqual(spaceSeparatedArgs.allowedUrlPattern, [ + 'https://a.com/*', + 'https://b.com/*', + ]); + }); }); diff --git a/tests/network_blocking.test.ts b/tests/network_blocking.test.ts new file mode 100644 index 000000000..fbc2279b3 --- /dev/null +++ b/tests/network_blocking.test.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import {emulate} from '../src/tools/emulation.js'; +import {lighthouseAudit} from '../src/tools/lighthouse.js'; +import {navigatePage} from '../src/tools/pages.js'; +import {evaluateScript} from '../src/tools/script.js'; + +import {serverHooks} from './server.js'; +import {withMcpContext} from './utils.js'; + +describe('Network Blocking Integration', () => { + const server = serverHooks(); + + it('blocks URLs in blocklist', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + const blockedUrlPattern = [server.getRoute('/blocked.html')]; + await withMcpContext( + async (response, context) => { + const allowedUrl = server.getRoute('/allowed.html'); + await navigatePage().handler( + { + params: {url: allowedUrl}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + `Successfully navigated to ${allowedUrl}.`, + ); + + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: {function: String(() => document.body.textContent)}, + }, + response, + context, + ); + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Allowed', + ); + + const blockedUrl = server.getRoute('/blocked.html'); + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: { + function: `async () => { + try { + await fetch("${blockedUrl}"); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }`, + }, + }, + response, + context, + ); + + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Failed to fetch', + ); + }, + { + blockedUrlPattern, + }, + ); + }); + + it('blocks URLs not in allowlist', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + const allowedUrlPattern = [server.getRoute('/allowed.html')]; + + await withMcpContext( + async (response, context) => { + const allowedUrl = server.getRoute('/allowed.html'); + await navigatePage().handler( + { + params: {url: allowedUrl}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + `Successfully navigated to ${allowedUrl}.`, + ); + + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: {function: String(() => document.body.textContent)}, + }, + response, + context, + ); + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Allowed', + ); + + const blockedUrl = server.getRoute('/blocked.html'); + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: { + function: `async () => { + try { + await fetch("${blockedUrl}"); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }`, + }, + }, + response, + context, + ); + + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Failed to fetch', + ); + }, + { + allowedUrlPattern, + }, + ); + }); + + it('respects blocklist after Lighthouse audits', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + const blockedUrlPattern = [server.getRoute('/blocked.html')]; + await withMcpContext( + async (response, context) => { + const allowedUrl = server.getRoute('/allowed.html'); + await navigatePage().handler( + { + params: {url: allowedUrl}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + `Successfully navigated to ${allowedUrl}.`, + ); + + const blockedUrl = server.getRoute('/blocked.html'); + + // Verifies fetch is blocked before Lighthouse audit + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: { + function: `async () => { + try { + await fetch("${blockedUrl}"); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }`, + }, + }, + response, + context, + ); + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Failed to fetch', + 'Fetch should be blocked before audit', + ); + + await lighthouseAudit.handler( + { + params: { + mode: 'navigation', + device: 'desktop', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + assert.equal( + response.attachedLighthouseResult?.summary.mode, + 'navigation', + ); + + // 2. Verify fetch remains blocked AFTER Lighthouse audit + response.resetResponseLineForTesting(); + await evaluateScript().handler( + { + params: { + function: `async () => { + try { + await fetch("${blockedUrl}"); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }`, + }, + }, + response, + context, + ); + assert.strictEqual( + JSON.parse(response.responseLines.at(2)!), + 'Failed to fetch', + 'Fetch should still be blocked after audit', + ); + }, + { + blockedUrlPattern, + }, + ); + }); + + it('throws error when trying to emulate network conditions while blocklist is configured', async () => { + const blockedUrlPattern = ['*://*/*']; + await withMcpContext( + async (response, context) => { + // Attempting to emulate network conditions should throw an error. + await assert.rejects(async () => { + await emulate.handler( + { + params: { + networkConditions: 'Offline', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + }, /Network throttling is not supported when network blocking \(allowlist\/blocklist\) is configured\./); + + // Attempting to emulate CPU rate or other things should succeed without errors. + await emulate.handler( + { + params: { + cpuThrottlingRate: 2, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + 'Emulation configured successfully', + ); + }, + { + blockedUrlPattern, + }, + ); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 1c362a96e..41c188ebd 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -74,6 +74,8 @@ export async function withBrowser( autoOpenDevTools?: boolean; executablePath?: string; args?: string[]; + blockedUrlPattern?: string[]; + allowedUrlPattern?: string[]; } = {}, ) { const launchOptions: LaunchOptions = { @@ -86,6 +88,8 @@ export async function withBrowser( handleDevToolsAsPage: true, args: [...(options.args || []), '--screen-info={3840x2160}'], enableExtensions: true, + blocklist: options.blockedUrlPattern, + allowlist: options.allowedUrlPattern, }; const key = JSON.stringify(launchOptions); @@ -115,6 +119,8 @@ export async function withMcpContext( performanceCrux?: boolean; executablePath?: string; args?: string[]; + blockedUrlPattern?: string[]; + allowedUrlPattern?: string[]; } = {}, args: ParsedArguments = {} as ParsedArguments, ) { @@ -130,6 +136,10 @@ export async function withMcpContext( { experimentalDevToolsDebugging: false, performanceCrux: options.performanceCrux ?? true, + hasNetworkBlockOrAllowlist: Boolean( + (options.blockedUrlPattern && options.blockedUrlPattern.length > 0) || + (options.allowedUrlPattern && options.allowedUrlPattern.length > 0), + ), }, Locator, );