diff --git a/README.md b/README.md index 1ffbafa00..1fabfa404 100644 --- a/README.md +++ b/README.md @@ -636,6 +636,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/scripts/test.mjs b/scripts/test.mjs index 0d427847f..46841f09b 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -103,6 +103,9 @@ async function runTests(attempt) { }); } +const chromePath = _installChrome('149.0.7827.14'); +process.env.CHROME_M149_EXECUTABLE_PATH = chromePath; + const maxAttempts = shouldRetry ? 3 : 1; let exitCode = 1; 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 3855c02f4..b1b415727 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,24 +347,27 @@ export class McpContext implements Context { const mcpPage = this.#getMcpPage(page); const newSettings: EmulationSettings = {...mcpPage.emulationSettings}; - if (!options.networkConditions) { - await page.emulateNetworkConditions(null); - delete newSettings.networkConditions; - } else if (options.networkConditions === 'Offline') { - await page.emulateNetworkConditions({ - offline: true, - download: 0, - upload: 0, - latency: 0, - }); - newSettings.networkConditions = 'Offline'; - } else if (options.networkConditions in PredefinedNetworkConditions) { - const networkCondition = - PredefinedNetworkConditions[ - options.networkConditions as keyof typeof PredefinedNetworkConditions - ]; - await page.emulateNetworkConditions(networkCondition); - newSettings.networkConditions = options.networkConditions; + // Skip network emulation if blocklist/allowlist is configured, as it is rejected by Puppeteer. + if (!this.#options.hasNetworkBlockOrAllowlist) { + if (!options.networkConditions) { + await page.emulateNetworkConditions(null); + delete newSettings.networkConditions; + } else if (options.networkConditions === 'Offline') { + await page.emulateNetworkConditions({ + offline: true, + download: 0, + upload: 0, + latency: 0, + }); + newSettings.networkConditions = 'Offline'; + } else if (options.networkConditions in PredefinedNetworkConditions) { + const networkCondition = + PredefinedNetworkConditions[ + options.networkConditions as keyof typeof PredefinedNetworkConditions + ]; + await page.emulateNetworkConditions(networkCondition); + newSettings.networkConditions = options.networkConditions; + } } const secondarySession = this.getDevToolsUniverse(mcpPage)?.session; diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index f510744d3..62bdfcf13 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -204,6 +204,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 20f254865..f40f56f79 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 d3c072fa4..325f1fb87 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 9982b1838..67368747f 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -295,5 +295,13 @@ { "name": "category_experimental_third_party", "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..30432899e 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,88 @@ 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 fetchResult = await page.evaluate(async url => { + try { + await fetch(url); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, server.getRoute('/blocked.html')); + + assert.strictEqual(fetchResult, 'Failed to fetch'); + } finally { + await browser.close(); + } + }); + + it( + 'blocks URLs not in allowlist', + {skip: 'Requires Chrome 149 or greater'}, + 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 fetchResult = await page.evaluate(async url => { + try { + await fetch(url); + return 'SUCCESS'; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, server.getRoute('/blocked.html')); + + assert.strictEqual(fetchResult, 'Failed to fetch'); + } finally { + await browser.close(); + } + }, + ); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 05f2d3b06..83f883c5e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -345,4 +345,62 @@ 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/*', + ]); + }); + + 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/*', + ]); + }); }); diff --git a/tests/network_blocking.test.ts b/tests/network_blocking.test.ts new file mode 100644 index 000000000..3204bc1a0 --- /dev/null +++ b/tests/network_blocking.test.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +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'); + + 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: [server.getRoute('/blocked.html')], + }, + ); + }); + + it('blocks URLs not in allowlist', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + 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: [server.getRoute('/allowed.html')], + executablePath: process.env.CHROME_M149_EXECUTABLE_PATH, + }, + ); + }); + + it('respects blocklist after Lighthouse audits', async () => { + server.addHtmlRoute('/allowed.html', 'Allowed'); + server.addHtmlRoute('/blocked.html', 'Blocked'); + + 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: [server.getRoute('/blocked.html')], + executablePath: process.env.CHROME_M149_EXECUTABLE_PATH, + }, + ); + }); +}); 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, );