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 @@ -268,6 +268,14 @@ The Chrome DevTools MCP server supports the following configuration option:
Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
- **Type:** string

- **`--allowedOrigins`**
Semicolon-separated list of origins the browser is allowed to request. If not specified, all origins are allowed (except those in blockedOrigins). Example: https://example.com;https://api.example.com
- **Type:** string

- **`--blockedOrigins`**
Semicolon-separated list of origins the browser is blocked from requesting. Takes precedence over allowedOrigins. Example: https://ads.example.com;https://tracker.example.com
- **Type:** string

<!-- END AUTO GENERATED OPTIONS -->

Pass them via the `args` property in the JSON configuration. For example:
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"core-js": "3.45.1",
"debug": "4.4.3",
"puppeteer-core": "24.22.3",
"yargs": "18.0.0"
"yargs": "18.0.0",
"zod": "3.24.1"
Copy link
Author

Choose a reason for hiding this comment

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

Build failed in local without zod, so I added. (I wonder if others don't have this issue.)

},
"devDependencies": {
"@eslint/js": "^9.35.0",
Expand Down
58 changes: 55 additions & 3 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {takeSnapshot} from './tools/snapshot.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import type {UrlValidator} from './utils/urlValidator.js';
import {WaitForHelper} from './WaitForHelper.js';

export interface TextSnapshotNode extends SerializedAXNode {
Expand Down Expand Up @@ -77,10 +78,16 @@ export class McpContext implements Context {

#nextSnapshotId = 1;
#traceResults: TraceResult[] = [];
#urlValidator?: UrlValidator;

private constructor(browser: Browser, logger: Debugger) {
private constructor(
browser: Browser,
logger: Debugger,
urlValidator?: UrlValidator,
) {
this.browser = browser;
this.logger = logger;
this.#urlValidator = urlValidator;

this.#networkCollector = new NetworkCollector(
this.browser,
Expand Down Expand Up @@ -109,10 +116,52 @@ export class McpContext implements Context {
this.setSelectedPageIdx(0);
await this.#networkCollector.init();
await this.#consoleCollector.init();
if (this.#urlValidator?.hasRestrictions()) {
await this.#setupRequestInterception();
}
}

async #setupRequestInterception() {
const pages = await this.browser.pages();
for (const page of pages) {
await this.#enableRequestInterceptionForPage(page);
}

this.browser.on('targetcreated', async target => {
const page = await target.page();
if (page) {
await this.#enableRequestInterceptionForPage(page);
}
});
}

static async from(browser: Browser, logger: Debugger) {
const context = new McpContext(browser, logger);
async #enableRequestInterceptionForPage(page: Page) {
try {
await page.setRequestInterception(true);

page.on('request', interceptedRequest => {
if (interceptedRequest.isInterceptResolutionHandled()) {
return;
}

const url = interceptedRequest.url();
if (this.#urlValidator && !this.#urlValidator.isAllowed(url)) {
void interceptedRequest.abort('blockedbyclient', 0);
} else {
void interceptedRequest.continue({}, 0);
}
});
} catch (error) {
this.logger(`Failed to enable request interception for page: ${error}`);
}
}

static async from(
browser: Browser,
logger: Debugger,
urlValidator?: UrlValidator,
) {
const context = new McpContext(browser, logger, urlValidator);
await context.#init();
return context;
}
Expand All @@ -133,6 +182,9 @@ export class McpContext implements Context {
this.setSelectedPageIdx(pages.indexOf(page));
this.#networkCollector.addPage(page);
this.#consoleCollector.addPage(page);
if (this.#urlValidator?.hasRestrictions()) {
await this.#enableRequestInterceptionForPage(page);
}
return page;
}
async closePage(pageIdx: number): Promise<void> {
Expand Down
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ export const cliOptions = {
describe:
'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.',
},
allowedOrigins: {
type: 'string' as const,
describe:
'Semicolon-separated list of origins the browser is allowed to request. If not specified, all origins are allowed (except those in blockedOrigins). Example: https://example.com;https://api.example.com',
},
blockedOrigins: {
type: 'string' as const,
describe:
'Semicolon-separated list of origins the browser is blocked from requesting. Takes precedence over allowedOrigins. Example: https://ads.example.com;https://tracker.example.com',
},
};

export function parseArguments(version: string, argv = process.argv) {
Expand All @@ -78,6 +88,14 @@ export function parseArguments(version: string, argv = process.argv) {
['$0 --channel dev', 'Use Chrome Dev installed on this system'],
['$0 --channel stable', 'Use stable Chrome installed on this system'],
['$0 --logFile /tmp/log.txt', 'Save logs to a file'],
[
'$0 --allowedOrigins "https://example.com;https://api.example.com"',
'Only allow requests to specific origins',
],
[
'$0 --blockedOrigins "https://ads.example.com;https://tracker.com"',
'Block requests to specific origins',
],
['$0 --help', 'Print CLI options'],
]);

Expand Down
11 changes: 10 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as screenshotTools from './tools/screenshot.js';
import * as scriptTools from './tools/script.js';
import * as snapshotTools from './tools/snapshot.js';
import type {ToolDefinition} from './tools/ToolDefinition.js';
import {UrlValidator} from './utils/urlValidator.js';

function readPackageJson(): {version?: string} {
const currentDir = import.meta.dirname;
Expand All @@ -55,6 +56,14 @@ export const args = parseArguments(version);
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;

logger(`Starting Chrome DevTools MCP Server v${version}`);

const allowedOrigins = UrlValidator.parseOrigins(args.allowedOrigins);
const blockedOrigins = UrlValidator.parseOrigins(args.blockedOrigins);
const urlValidator =
allowedOrigins.length > 0 || blockedOrigins.length > 0
? new UrlValidator({allowedOrigins, blockedOrigins}, logger)
: undefined;

const server = new McpServer(
{
name: 'chrome_devtools',
Expand All @@ -79,7 +88,7 @@ async function getContext(): Promise<McpContext> {
logFile,
});
if (context?.browser !== browser) {
context = await McpContext.from(browser, logger);
context = await McpContext.from(browser, logger, urlValidator);
}
return context;
}
Expand Down
117 changes: 117 additions & 0 deletions src/utils/urlValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type {Debugger} from 'debug';

export class UrlValidator {
#allowedOrigins: string[];
#blockedOrigins: string[];
#logger: Debugger;

constructor(
options: {
allowedOrigins?: string[];
blockedOrigins?: string[];
},
logger: Debugger,
) {
this.#allowedOrigins = options.allowedOrigins ?? [];
this.#blockedOrigins = options.blockedOrigins ?? [];
this.#logger = logger;

if (this.#allowedOrigins.length > 0) {
this.#logger(
`URL validation enabled. Allowed origins: ${this.#allowedOrigins.join(', ')}`,
);
}
if (this.#blockedOrigins.length > 0) {
this.#logger(
`URL validation enabled. Blocked origins: ${this.#blockedOrigins.join(', ')}`,
);
}
}

static parseOrigins(originsString?: string): string[] {
if (!originsString) {
return [];
}
return originsString
.split(';')
.map(o => o.trim())
.filter(o => o.length > 0);
}

isAllowed(url: string): boolean {
if (this.#isSpecialUrl(url)) {
return true;
}

try {
const origin = new URL(url).origin;

if (this.#matchesAnyOrigin(origin, this.#blockedOrigins)) {
this.#logger(`Blocked request to ${url} (origin: ${origin})`);
return false;
}

if (this.#allowedOrigins.length === 0) {
return true;
}

const allowed = this.#matchesAnyOrigin(origin, this.#allowedOrigins);
if (!allowed) {
this.#logger(
`Blocked request to ${url} (origin: ${origin} not in allowlist)`,
);
}
return allowed;
} catch {
return true;
}
}

#isSpecialUrl(url: string): boolean {
const lowerUrl = url.toLowerCase();
return (
lowerUrl.startsWith('about:') ||
lowerUrl.startsWith('data:') ||
lowerUrl.startsWith('blob:') ||
lowerUrl.startsWith('file:')
);
}

#matchesAnyOrigin(origin: string, patterns: string[]): boolean {
for (const pattern of patterns) {
if (this.#matchesOriginPattern(origin, pattern)) {
return true;
}
}
return false;
}

#matchesOriginPattern(origin: string, pattern: string): boolean {
if (origin === pattern) {
return true;
}

if (pattern.includes('*')) {
const regex = this.#patternToRegex(pattern);
return regex.test(origin);
}

return false;
}

#patternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
const regexPattern = escaped.replace(/\*/g, '[^\\/]+');
return new RegExp(`^${regexPattern}$`);
}

hasRestrictions(): boolean {
return this.#allowedOrigins.length > 0 || this.#blockedOrigins.length > 0;
}
}
5 changes: 5 additions & 0 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import logger from 'debug';
import type {Debugger} from 'debug';
import type {Browser} from 'puppeteer';
import puppeteer from 'puppeteer';
import type {HTTPRequest, HTTPResponse} from 'puppeteer-core';

import {McpContext} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';

export function createLogger(namespace = 'test'): Debugger {
return logger(namespace);
}

let browser: Browser | undefined;

export async function withBrowser(
Expand Down
Loading