Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ async function runTests(attempt) {
});
}

const chromePath = _installChrome('149.0.7827.14');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this this is already stable https://chromestatus.com/roadmap
And that is the version pinned by Puppeteer.

process.env.CHROME_M149_EXECUTABLE_PATH = chromePath;

const maxAttempts = shouldRetry ? 3 : 1;
let exitCode = 1;

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
41 changes: 23 additions & 18 deletions 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,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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we throw if we try to set emulation? Reason is I try to emulate network conditions and this just silently ignores it, an LLM most likely will be confused.

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;
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 @@ -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:
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 @@ -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"
}
]
86 changes: 86 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,88 @@ 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 fetchResult = await page.evaluate(async url => {
try {
await fetch(url);
return 'SUCCESS';
} catch (err) {
return err instanceof Error ? err.message : String(err);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We can just return true or false as the message may change.

}
}, 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'},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this also still needed?

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 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();
}
},
);
});
});
Loading
Loading