Skip to content
Merged
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
51 changes: 51 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export function extractUrlLikeFromDevToolsTitle(
title: string,
): string | undefined {
const match = title.match(new RegExp(`DevTools - (.*)`));
return match?.[1] ?? undefined;
}

export function urlsEqual(url1: string, url2: string): boolean {
const normalizedUrl1 = normalizeUrl(url1);
const normalizedUrl2 = normalizeUrl(url2);
return normalizedUrl1 === normalizedUrl2;
}

/**
* For the sake of the MCP server, when we determine if two URLs are equal we
* remove some parts:
*
* 1. We do not care about the protocol.
* 2. We do not care about trailing slashes.
* 3. We do not care about "www".
*
* For example, if the user types "record a trace on foo.com", we would want to
* match a tab in the connected Chrome instance that is showing "www.foo.com/"
*/
function normalizeUrl(url: string): string {
let result = url.trim();

// Remove protocols
if (result.startsWith('https://')) {
result = result.slice(8);
} else if (result.startsWith('http://')) {
result = result.slice(7);
}

// Remove 'www.'. This ensures that we find the right URL regardless of if the user adds `www` or not.
if (result.startsWith('www.')) {
result = result.slice(4);
}

// Remove trailing slash
if (result.endsWith('/')) {
result = result.slice(0, -1);
}

return result;
}
54 changes: 47 additions & 7 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
import type {ListenerMap} from './PageCollector.js';
import {NetworkCollector, PageCollector} from './PageCollector.js';
import {Locator} from './third_party/index.js';
Expand Down Expand Up @@ -40,8 +41,8 @@ export interface TextSnapshot {
}

interface McpContextOptions {
// Whether the DevTools windows are exposed as pages.
devtools: boolean;
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
experimentalDevToolsDebugging: boolean;
}

const DEFAULT_TIMEOUT = 5_000;
Expand Down Expand Up @@ -82,6 +83,7 @@ export class McpContext implements Context {

// The most recent page state.
#pages: Page[] = [];
#pageToDevToolsPage = new Map<Page, Page>();
#selectedPageIdx = 0;
// The most recent snapshot.
#textSnapshot: TextSnapshot | null = null;
Expand Down Expand Up @@ -324,19 +326,57 @@ export class McpContext implements Context {
* Creates a snapshot of the pages.
*/
async createPagesSnapshot(): Promise<Page[]> {
this.#pages = (await this.browser.pages()).filter(page => {
if (page.url().startsWith('devtools://')) {
return this.#options.devtools;
}
return true;
const allPages = await this.browser.pages();

this.#pages = allPages.filter(page => {
// If we allow debugging DevTools windows, return all pages.
// If we are in regular mode, the user should only see non-DevTools page.
return (
this.#options.experimentalDevToolsDebugging ||
!page.url().startsWith('devtools://')
);
});

await this.#detectOpenDevToolsWindows(allPages);

return this.#pages;
}

async #detectOpenDevToolsWindows(pages: Page[]) {
this.#pageToDevToolsPage = new Map<Page, Page>();
for (const devToolsPage of pages) {
if (devToolsPage.url().startsWith('devtools://')) {
try {
const data = await devToolsPage
// @ts-expect-error no types for _client().
._client()
.send('Target.getTargetInfo');
const devtoolsPageTitle = data.targetInfo.title;
const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle);
if (!urlLike) {
continue;
}
// TODO: lookup without a loop.
for (const page of this.#pages) {
if (urlsEqual(page.url(), urlLike)) {
this.#pageToDevToolsPage.set(page, devToolsPage);
}
}
} catch {
// no-op
}
}
}
}

getPages(): Page[] {
return this.#pages;
}

getDevToolsPage(page: Page): Page | undefined {
return this.#pageToDevToolsPage.get(page);
}

/**
* Creates a text snapshot of a page.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function ensureBrowserConnected(options: {
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: makeTargetFilter(),
defaultViewport: null,
handleDevToolsAsPage: options.devtools,
handleDevToolsAsPage: true,
};

if (options.wsEndpoint) {
Expand Down Expand Up @@ -134,7 +134,7 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
headless,
args,
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: options.devtools,
handleDevToolsAsPage: true,
});
if (options.logFile) {
// FIXME: we are probably subscribing too late to catch startup logs. We
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async function getContext(): Promise<McpContext> {

if (context?.browser !== browser) {
context = await McpContext.from(browser, logger, {
devtools,
experimentalDevToolsDebugging: devtools,
});
}
return context;
Expand Down
71 changes: 71 additions & 0 deletions tests/DevtoolsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';

import {
extractUrlLikeFromDevToolsTitle,
urlsEqual,
} from '../src/DevtoolsUtils.js';

describe('extractUrlFromDevToolsTitle', () => {
it('deals with no trailing /', () => {
assert.strictEqual(
extractUrlLikeFromDevToolsTitle('DevTools - example.com'),
'example.com',
);
});
it('deals with a trailing /', () => {
assert.strictEqual(
extractUrlLikeFromDevToolsTitle('DevTools - example.com/'),
'example.com/',
);
});
it('deals with www', () => {
assert.strictEqual(
extractUrlLikeFromDevToolsTitle('DevTools - www.example.com/'),
'www.example.com/',
);
});
it('deals with complex url', () => {
assert.strictEqual(
extractUrlLikeFromDevToolsTitle(
'DevTools - www.example.com/path.html?a=b#3',
),
'www.example.com/path.html?a=b#3',
);
});
});

describe('urlsEqual', () => {
it('ignores trailing slashes', () => {
assert.strictEqual(
urlsEqual('https://google.com/', 'https://google.com'),
true,
);
});

it('ignores www', () => {
assert.strictEqual(
urlsEqual('https://google.com/', 'https://www.google.com'),
true,
);
});

it('ignores protocols', () => {
assert.strictEqual(
urlsEqual('https://google.com/', 'http://www.google.com'),
true,
);
});

it('does not ignore other subdomains', () => {
assert.strictEqual(
urlsEqual('https://google.com/', 'https://photos.google.com'),
false,
);
});
});
18 changes: 18 additions & 0 deletions tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,22 @@ describe('McpContext', () => {
sinon.assert.calledWithExactly(stub, page, 2, 10);
});
});

it('should should detect open DevTools pages', async () => {
await withBrowser(
async (_response, context) => {
const page = await context.newPage();
// TODO: we do not know when the CLI flag to auto open DevTools will run
// so we need this until
// https://github.com/puppeteer/puppeteer/issues/14368 is there.
await new Promise(resolve => setTimeout(resolve, 5000));
await context.createPagesSnapshot();
assert.ok(context.getDevToolsPage(page));
},
{
autoOpenDevToos: true,
force: true,
},
);
});
});
8 changes: 5 additions & 3 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ let browser: Browser | undefined;

export async function withBrowser(
cb: (response: McpResponse, context: McpContext) => Promise<void>,
options: {debug?: boolean} = {},
options: {debug?: boolean; autoOpenDevToos?: boolean; force?: boolean} = {},
) {
const {debug = false} = options;
if (!browser) {
if (!browser || options.force) {
browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: !debug,
defaultViewport: null,
devtools: options.autoOpenDevToos ?? false,
handleDevToolsAsPage: true,
});
}
const newPage = await browser.newPage();
Expand All @@ -40,7 +42,7 @@ export async function withBrowser(
browser,
logger('test'),
{
devtools: false,
experimentalDevToolsDebugging: false,
},
Locator,
);
Expand Down