Skip to content

Commit 6d68055

Browse files
Merge branch 'main' into browser-url
2 parents f67de3f + d892145 commit 6d68055

33 files changed

+703
-427
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,12 @@ The Chrome DevTools MCP server supports the following configuration option:
343343
- **Type:** string
344344

345345
- **`--isolated`**
346-
If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.
346+
If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
347347
- **Type:** boolean
348-
- **Default:** `false`
348+
349+
- **`--userDataDir`**
350+
Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
351+
- **Type:** string
349352

350353
- **`--channel`**
351354
Specify a different Chrome channel that should be used. The default is the stable channel version.

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
**Parameters:**
197197

198198
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.
199+
- **geolocation** (unknown) _(optional)_: Geolocation to [`emulate`](#emulate). Set to null to clear the geolocation override.
199200
- **networkConditions** (enum: "No emulation", "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.
200201

201202
---

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"@types/yargs": "^17.0.33",
5353
"@typescript-eslint/eslint-plugin": "^8.43.0",
5454
"@typescript-eslint/parser": "^8.43.0",
55-
"chrome-devtools-frontend": "1.0.1549484",
55+
"chrome-devtools-frontend": "1.0.1550444",
5656
"core-js": "3.47.0",
5757
"debug": "4.4.3",
5858
"eslint": "^9.35.0",

scripts/post-build.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export const LOCAL_FETCH_PATTERN = './locales/@[email protected]';`;
8888
const runtimeContent = `
8989
export function getChromeVersion() { return ''; };
9090
export const hostConfig = {};
91+
export const Runtime = {
92+
isDescriptorEnabled: () => true,
93+
queryParam: () => null,
94+
}
95+
export const experiments = {
96+
isEnabled: () => false,
97+
}
9198
`;
9299
writeFile(runtimeFile, runtimeContent);
93100

src/DevtoolsUtils.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,26 @@ import {
88
type Issue,
99
type AggregatedIssue,
1010
type IssuesManagerEventTypes,
11+
type Target,
12+
DebuggerModel,
13+
Foundation,
14+
TargetManager,
1115
MarkdownIssueDescription,
1216
Marked,
17+
ProtocolClient,
1318
Common,
1419
I18n,
1520
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
1621

22+
import {PuppeteerDevToolsConnection} from './DevToolsConnectionAdapter.js';
1723
import {ISSUE_UTILS} from './issue-descriptions.js';
1824
import {logger} from './logger.js';
25+
import {Mutex} from './Mutex.js';
26+
import type {
27+
Browser,
28+
Page,
29+
Target as PuppeteerTarget,
30+
} from './third_party/index.js';
1931

2032
export function extractUrlLikeFromDevToolsTitle(
2133
title: string,
@@ -125,6 +137,9 @@ export function mapIssueToMessageObject(issue: AggregatedIssue) {
125137
};
126138
}
127139

140+
// DevTools CDP errors can get noisy.
141+
ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
142+
128143
I18n.DevToolsLocale.DevToolsLocale.instance({
129144
create: true,
130145
data: {
@@ -134,3 +149,129 @@ I18n.DevToolsLocale.DevToolsLocale.instance({
134149
},
135150
});
136151
I18n.i18n.registerLocaleDataForTest('en-US', {});
152+
153+
export interface TargetUniverse {
154+
/** The DevTools target corresponding to the puppeteer Page */
155+
target: Target;
156+
universe: Foundation.Universe.Universe;
157+
}
158+
export type TargetUniverseFactoryFn = (page: Page) => Promise<TargetUniverse>;
159+
160+
export class UniverseManager {
161+
readonly #browser: Browser;
162+
readonly #createUniverseFor: TargetUniverseFactoryFn;
163+
readonly #universes = new WeakMap<Page, TargetUniverse>();
164+
165+
/** Guard access to #universes so we don't create unnecessary universes */
166+
readonly #mutex = new Mutex();
167+
168+
constructor(
169+
browser: Browser,
170+
factory: TargetUniverseFactoryFn = DEFAULT_FACTORY,
171+
) {
172+
this.#browser = browser;
173+
this.#createUniverseFor = factory;
174+
}
175+
176+
async init(pages: Page[]) {
177+
try {
178+
await this.#mutex.acquire();
179+
const promises = [];
180+
for (const page of pages) {
181+
promises.push(
182+
this.#createUniverseFor(page).then(targetUniverse =>
183+
this.#universes.set(page, targetUniverse),
184+
),
185+
);
186+
}
187+
188+
this.#browser.on('targetcreated', this.#onTargetCreated);
189+
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
190+
191+
await Promise.all(promises);
192+
} finally {
193+
this.#mutex.release();
194+
}
195+
}
196+
197+
get(page: Page): TargetUniverse | null {
198+
return this.#universes.get(page) ?? null;
199+
}
200+
201+
dispose() {
202+
this.#browser.off('targetcreated', this.#onTargetCreated);
203+
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
204+
}
205+
206+
#onTargetCreated = async (target: PuppeteerTarget) => {
207+
const page = await target.page();
208+
try {
209+
await this.#mutex.acquire();
210+
if (!page || this.#universes.has(page)) {
211+
return;
212+
}
213+
214+
this.#universes.set(page, await this.#createUniverseFor(page));
215+
} finally {
216+
this.#mutex.release();
217+
}
218+
};
219+
220+
#onTargetDestroyed = async (target: PuppeteerTarget) => {
221+
const page = await target.page();
222+
try {
223+
await this.#mutex.acquire();
224+
if (!page || !this.#universes.has(page)) {
225+
return;
226+
}
227+
this.#universes.delete(page);
228+
} finally {
229+
this.#mutex.release();
230+
}
231+
};
232+
}
233+
234+
const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
235+
const settingStorage = new Common.Settings.SettingsStorage({});
236+
const universe = new Foundation.Universe.Universe({
237+
settingsCreationOptions: {
238+
syncedStorage: settingStorage,
239+
globalStorage: settingStorage,
240+
localStorage: settingStorage,
241+
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
242+
},
243+
overrideAutoStartModels: new Set([DebuggerModel]),
244+
});
245+
246+
const session = await page.createCDPSession();
247+
const connection = new PuppeteerDevToolsConnection(session);
248+
249+
const targetManager = universe.context.get(TargetManager);
250+
targetManager.observeModels(DebuggerModel, SKIP_ALL_PAUSES);
251+
252+
const target = targetManager.createTarget(
253+
'main',
254+
'',
255+
'frame' as any, // eslint-disable-line @typescript-eslint/no-explicit-any
256+
/* parentTarget */ null,
257+
session.id(),
258+
undefined,
259+
connection,
260+
);
261+
return {target, universe};
262+
};
263+
264+
// We don't want to pause any DevTools universe session ever on the MCP side.
265+
//
266+
// Note that calling `setSkipAllPauses` only affects the session on which it was
267+
// sent. This means DevTools can still pause, step and do whatever. We just won't
268+
// see the `Debugger.paused`/`Debugger.resumed` events on the MCP side.
269+
const SKIP_ALL_PAUSES = {
270+
modelAdded(model: DebuggerModel): void {
271+
void model.agent.invoke_setSkipAllPauses({skip: true});
272+
},
273+
274+
modelRemoved(): void {
275+
// Do nothing.
276+
},
277+
};

src/McpContext.ts

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface TextSnapshotNode extends SerializedAXNode {
3838
children: TextSnapshotNode[];
3939
}
4040

41+
export interface GeolocationOptions {
42+
latitude: number;
43+
longitude: number;
44+
}
45+
4146
export interface TextSnapshot {
4247
root: TextSnapshotNode;
4348
idToNode: Map<string, TextSnapshotNode>;
@@ -104,6 +109,7 @@ export class McpContext implements Context {
104109
#isRunningTrace = false;
105110
#networkConditionsMap = new WeakMap<Page, string>();
106111
#cpuThrottlingRateMap = new WeakMap<Page, number>();
112+
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
107113
#dialog?: Dialog;
108114

109115
#nextSnapshotId = 1;
@@ -123,41 +129,33 @@ export class McpContext implements Context {
123129
this.#locatorClass = locatorClass;
124130
this.#options = options;
125131

126-
this.#networkCollector = new NetworkCollector(
127-
this.browser,
128-
undefined,
129-
this.#options.experimentalIncludeAllPages,
130-
);
132+
this.#networkCollector = new NetworkCollector(this.browser);
131133

132-
this.#consoleCollector = new ConsoleCollector(
133-
this.browser,
134-
collect => {
135-
return {
136-
console: event => {
137-
collect(event);
138-
},
139-
pageerror: event => {
140-
if (event instanceof Error) {
141-
collect(event);
142-
} else {
143-
const error = new Error(`${event}`);
144-
error.stack = undefined;
145-
collect(error);
146-
}
147-
},
148-
issue: event => {
134+
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
135+
return {
136+
console: event => {
137+
collect(event);
138+
},
139+
pageerror: event => {
140+
if (event instanceof Error) {
149141
collect(event);
150-
},
151-
} as ListenerMap;
152-
},
153-
this.#options.experimentalIncludeAllPages,
154-
);
142+
} else {
143+
const error = new Error(`${event}`);
144+
error.stack = undefined;
145+
collect(error);
146+
}
147+
},
148+
issue: event => {
149+
collect(event);
150+
},
151+
} as ListenerMap;
152+
});
155153
}
156154

157155
async #init() {
158-
await this.createPagesSnapshot();
159-
await this.#networkCollector.init();
160-
await this.#consoleCollector.init();
156+
const pages = await this.createPagesSnapshot();
157+
await this.#networkCollector.init(pages);
158+
await this.#consoleCollector.init(pages);
161159
}
162160

163161
dispose() {
@@ -285,6 +283,20 @@ export class McpContext implements Context {
285283
return this.#cpuThrottlingRateMap.get(page) ?? 1;
286284
}
287285

286+
setGeolocation(geolocation: GeolocationOptions | null): void {
287+
const page = this.getSelectedPage();
288+
if (geolocation === null) {
289+
this.#geolocationMap.delete(page);
290+
} else {
291+
this.#geolocationMap.set(page, geolocation);
292+
}
293+
}
294+
295+
getGeolocation(): GeolocationOptions | null {
296+
const page = this.getSelectedPage();
297+
return this.#geolocationMap.get(page) ?? null;
298+
}
299+
288300
setIsRunningPerformanceTrace(x: boolean): void {
289301
this.#isRunningTrace = x;
290302
}
@@ -625,25 +637,19 @@ export class McpContext implements Context {
625637
return this.#networkCollector.getIdForResource(request);
626638
}
627639

628-
waitForTextOnPage({
629-
text,
630-
timeout,
631-
}: {
632-
text: string;
633-
timeout?: number | undefined;
634-
}): Promise<Element> {
640+
waitForTextOnPage(text: string, timeout?: number): Promise<Element> {
635641
const page = this.getSelectedPage();
636642
const frames = page.frames();
637643

638-
const locator = this.#locatorClass.race(
644+
let locator = this.#locatorClass.race(
639645
frames.flatMap(frame => [
640646
frame.locator(`aria/${text}`),
641647
frame.locator(`text/${text}`),
642648
]),
643649
);
644650

645651
if (timeout) {
646-
locator.setTimeout(timeout);
652+
locator = locator.setTimeout(timeout);
647653
}
648654

649655
return locator.wait();
@@ -663,6 +669,6 @@ export class McpContext implements Context {
663669
},
664670
} as ListenerMap;
665671
});
666-
await this.#networkCollector.init();
672+
await this.#networkCollector.init(await this.browser.pages());
667673
}
668674
}

0 commit comments

Comments
 (0)