chrome-devtools-mcp 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +52 -25
  2. package/build/src/DevtoolsUtils.js +59 -42
  3. package/build/src/McpContext.js +106 -18
  4. package/build/src/McpResponse.js +240 -146
  5. package/build/src/browser.js +4 -2
  6. package/build/src/cli.js +39 -3
  7. package/build/src/formatters/ConsoleFormatter.js +153 -0
  8. package/build/src/formatters/IssueFormatter.js +190 -0
  9. package/build/src/formatters/NetworkFormatter.js +226 -0
  10. package/build/src/formatters/SnapshotFormatter.js +134 -0
  11. package/build/src/logger.js +9 -0
  12. package/build/src/main.js +51 -7
  13. package/build/src/telemetry/clearcut-logger.js +99 -0
  14. package/build/src/telemetry/flag-utils.js +45 -0
  15. package/build/src/telemetry/metric-utils.js +14 -0
  16. package/build/src/telemetry/persistence.js +53 -0
  17. package/build/src/telemetry/types.js +33 -0
  18. package/build/src/telemetry/watchdog/clearcut-sender.js +48 -0
  19. package/build/src/telemetry/watchdog/main.js +98 -0
  20. package/build/src/telemetry/watchdog-client.js +51 -0
  21. package/build/src/third_party/THIRD_PARTY_NOTICES +0 -1417
  22. package/build/src/third_party/devtools-formatter-worker.js +15451 -0
  23. package/build/src/third_party/index.js +2452 -991
  24. package/build/src/tools/categories.js +2 -0
  25. package/build/src/tools/emulation.js +62 -1
  26. package/build/src/tools/extensions.js +79 -0
  27. package/build/src/tools/input.js +88 -12
  28. package/build/src/tools/network.js +17 -3
  29. package/build/src/tools/pages.js +117 -53
  30. package/build/src/tools/performance.js +38 -27
  31. package/build/src/tools/tools.js +2 -0
  32. package/build/src/utils/ExtensionRegistry.js +35 -0
  33. package/build/src/utils/string.js +36 -0
  34. package/package.json +15 -14
  35. package/build/src/formatters/consoleFormatter.js +0 -121
  36. package/build/src/formatters/networkFormatter.js +0 -77
  37. package/build/src/formatters/snapshotFormatter.js +0 -73
  38. package/build/src/third_party/devtools.js +0 -6
  39. package/build/src/third_party/issue-descriptions/SameSiteInvalidSameParty.md +0 -8
  40. package/build/src/third_party/issue-descriptions/SameSiteSamePartyCrossPartyContextSet.md +0 -10
package/README.md CHANGED
@@ -66,7 +66,7 @@ amp mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
66
66
  <details>
67
67
  <summary>Antigravity</summary>
68
68
 
69
- To use the Chrome DevTools MCP server follow the instructions from <a href="https://antigravity.google/docs/mcp">Antigravity's docs<a/> to install a custom MCP server. Add the following config to the MCP servers config:
69
+ To use the Chrome DevTools MCP server follow the instructions from <a href="https://antigravity.google/docs/mcp">Antigravity's docs</a> to install a custom MCP server. Add the following config to the MCP servers config:
70
70
 
71
71
  ```bash
72
72
  {
@@ -91,10 +91,10 @@ Chrome DevTools MCP will not start the browser instance automatically using this
91
91
 
92
92
  <details>
93
93
  <summary>Claude Code</summary>
94
- Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://docs.anthropic.com/en/docs/claude-code/mcp">guide</a>):
94
+ Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://code.claude.com/docs/en/mcp">guide</a>):
95
95
 
96
96
  ```bash
97
- claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
97
+ claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
98
98
  ```
99
99
 
100
100
  </details>
@@ -205,7 +205,10 @@ Install the Chrome DevTools MCP server using the Gemini CLI.
205
205
  **Project wide:**
206
206
 
207
207
  ```bash
208
+ # Either MCP only:
208
209
  gemini mcp add chrome-devtools npx chrome-devtools-mcp@latest
210
+ # Or as a Gemini extension (MCP+Skills):
211
+ gemini extensions install --auto-update https://github.com/ChromeDevTools/chrome-devtools-mcp
209
212
  ```
210
213
 
211
214
  **Globally:**
@@ -241,6 +244,25 @@ Or, from the IDE **Activity Bar** > `Kiro` > `MCP Servers` > `Click Open MCP Con
241
244
 
242
245
  </details>
243
246
 
247
+ <details>
248
+ <summary>OpenCode</summary>
249
+
250
+ Add the following configuration to your `opencode.json` file. If you don't have one, create it at `~/.config/opencode/opencode.json` (<a href="https://opencode.ai/docs/mcp-servers">guide</a>):
251
+
252
+ ```json
253
+ {
254
+ "$schema": "https://opencode.ai/config.json",
255
+ "mcp": {
256
+ "chrome-devtools": {
257
+ "type": "local",
258
+ "command": ["npx", "-y", "chrome-devtools-mcp@latest"]
259
+ }
260
+ }
261
+ }
262
+ ```
263
+
264
+ </details>
265
+
244
266
  <details>
245
267
  <summary>Qoder</summary>
246
268
 
@@ -350,20 +372,20 @@ The Chrome DevTools MCP server supports the following configuration option:
350
372
 
351
373
  <!-- BEGIN AUTO GENERATED OPTIONS -->
352
374
 
353
- - **`--autoConnect`**
354
- If specified, automatically connects to a browser (Chrome 145+) running in the user data directory identified by the channel param. Requires remote debugging being enabled in Chrome here: chrome://inspect/#remote-debugging.
375
+ - **`--autoConnect`/ `--auto-connect`**
376
+ If specified, automatically connects to a browser (Chrome 144+) running in the user data directory identified by the channel param. Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.
355
377
  - **Type:** boolean
356
378
  - **Default:** `false`
357
379
 
358
- - **`--browserUrl`, `-u`**
380
+ - **`--browserUrl`/ `--browser-url`, `-u`**
359
381
  Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.
360
382
  - **Type:** string
361
383
 
362
- - **`--wsEndpoint`, `-w`**
384
+ - **`--wsEndpoint`/ `--ws-endpoint`, `-w`**
363
385
  WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.
364
386
  - **Type:** string
365
387
 
366
- - **`--wsHeaders`**
388
+ - **`--wsHeaders`/ `--ws-headers`**
367
389
  Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --wsEndpoint.
368
390
  - **Type:** string
369
391
 
@@ -372,7 +394,7 @@ The Chrome DevTools MCP server supports the following configuration option:
372
394
  - **Type:** boolean
373
395
  - **Default:** `false`
374
396
 
375
- - **`--executablePath`, `-e`**
397
+ - **`--executablePath`/ `--executable-path`, `-e`**
376
398
  Path to custom Chrome executable.
377
399
  - **Type:** string
378
400
 
@@ -380,7 +402,7 @@ The Chrome DevTools MCP server supports the following configuration option:
380
402
  If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
381
403
  - **Type:** boolean
382
404
 
383
- - **`--userDataDir`**
405
+ - **`--userDataDir`/ `--user-data-dir`**
384
406
  Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
385
407
  - **Type:** string
386
408
 
@@ -389,7 +411,7 @@ The Chrome DevTools MCP server supports the following configuration option:
389
411
  - **Type:** string
390
412
  - **Choices:** `stable`, `canary`, `beta`, `dev`
391
413
 
392
- - **`--logFile`**
414
+ - **`--logFile`/ `--log-file`**
393
415
  Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
394
416
  - **Type:** string
395
417
 
@@ -397,29 +419,33 @@ The Chrome DevTools MCP server supports the following configuration option:
397
419
  Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
398
420
  - **Type:** string
399
421
 
400
- - **`--proxyServer`**
422
+ - **`--proxyServer`/ `--proxy-server`**
401
423
  Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.
402
424
  - **Type:** string
403
425
 
404
- - **`--acceptInsecureCerts`**
426
+ - **`--acceptInsecureCerts`/ `--accept-insecure-certs`**
405
427
  If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
406
428
  - **Type:** boolean
407
429
 
408
- - **`--chromeArg`**
430
+ - **`--chromeArg`/ `--chrome-arg`**
409
431
  Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
410
432
  - **Type:** array
411
433
 
412
- - **`--categoryEmulation`**
434
+ - **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
435
+ Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
436
+ - **Type:** array
437
+
438
+ - **`--categoryEmulation`/ `--category-emulation`**
413
439
  Set to false to exclude tools related to emulation.
414
440
  - **Type:** boolean
415
441
  - **Default:** `true`
416
442
 
417
- - **`--categoryPerformance`**
443
+ - **`--categoryPerformance`/ `--category-performance`**
418
444
  Set to false to exclude tools related to performance.
419
445
  - **Type:** boolean
420
446
  - **Default:** `true`
421
447
 
422
- - **`--categoryNetwork`**
448
+ - **`--categoryNetwork`/ `--category-network`**
423
449
  Set to false to exclude tools related to network.
424
450
  - **Type:** boolean
425
451
  - **Default:** `true`
@@ -499,7 +525,7 @@ In these cases, start Chrome first and let the Chrome DevTools MCP server connec
499
525
 
500
526
  **Step 1:** Set up remote debugging in Chrome
501
527
 
502
- In Chrome, do the following to set up remote debugging:
528
+ In Chrome (\>= M144), do the following to set up remote debugging:
503
529
 
504
530
  1. Navigate to `chrome://inspect/#remote-debugging` to enable remote debugging.
505
531
  2. Follow the dialog UI to allow or disallow incoming debugging connections.
@@ -516,17 +542,13 @@ The following code snippet is an example configuration for gemini-cli:
516
542
  "mcpServers": {
517
543
  "chrome-devtools": {
518
544
  "command": "npx",
519
- "args": [
520
- "chrome-devtools-mcp@latest",
521
- "--autoConnect",
522
- "--channel=canary"
523
- ]
545
+ "args": ["chrome-devtools-mcp@latest", "--autoConnect", "--channel=beta"]
524
546
  }
525
547
  }
526
548
  }
527
549
  ```
528
550
 
529
- Note: you have to specify `--channel=canary` until Chrome M144 has reached the
551
+ Note: you have to specify `--channel=beta` until Chrome M144 has reached the
530
552
  stable channel.
531
553
 
532
554
  **Step 3:** Test your setup
@@ -537,7 +559,8 @@ Make sure your browser is running. Open gemini-cli and run the following prompt:
537
559
  Check the performance of https://developers.chrome.com
538
560
  ```
539
561
 
540
- Note: The <code>autoConnect</code> option requires the user to start Chrome.
562
+ > [!NOTE]
563
+ > The <code>autoConnect</code> option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile.
541
564
 
542
565
  The Chrome DevTools MCP server will try to connect to your running Chrome
543
566
  instance. It shows a dialog asking for user permission.
@@ -611,6 +634,10 @@ If you hit VM-to-host port forwarding issues, see the “Remote debugging betwee
611
634
 
612
635
  For more details on remote debugging, see the [Chrome DevTools documentation](https://developer.chrome.com/docs/devtools/remote-debugging/).
613
636
 
637
+ ### Debugging Chrome on Android
638
+
639
+ Please consult [these instructions](./docs/debugging-android.md).
640
+
614
641
  ## Known limitations
615
642
 
616
643
  ### Operating system sandboxes
@@ -4,8 +4,6 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import { PuppeteerDevToolsConnection } from './DevToolsConnectionAdapter.js';
7
- import { ISSUE_UTILS } from './issue-descriptions.js';
8
- import { logger } from './logger.js';
9
7
  import { Mutex } from './Mutex.js';
10
8
  import { DevTools } from './third_party/index.js';
11
9
  export function extractUrlLikeFromDevToolsTitle(title) {
@@ -64,46 +62,6 @@ export class FakeIssuesManager extends DevTools.Common.ObjectWrapper
64
62
  return [];
65
63
  }
66
64
  }
67
- export function mapIssueToMessageObject(issue) {
68
- const count = issue.getAggregatedIssuesCount();
69
- const markdownDescription = issue.getDescription();
70
- const filename = markdownDescription?.file;
71
- if (!markdownDescription) {
72
- logger(`no description found for issue:` + issue.code);
73
- return null;
74
- }
75
- const rawMarkdown = filename
76
- ? ISSUE_UTILS.getIssueDescription(filename)
77
- : null;
78
- if (!rawMarkdown) {
79
- logger(`no markdown ${filename} found for issue:` + issue.code);
80
- return null;
81
- }
82
- let processedMarkdown;
83
- let title;
84
- try {
85
- processedMarkdown =
86
- DevTools.MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription.substitutions);
87
- const markdownAst = DevTools.Marked.Marked.lexer(processedMarkdown);
88
- title =
89
- DevTools.MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
90
- }
91
- catch {
92
- logger('error parsing markdown for issue ' + issue.code());
93
- return null;
94
- }
95
- if (!title) {
96
- logger('cannot read issue title from ' + filename);
97
- return null;
98
- }
99
- return {
100
- type: 'issue',
101
- item: issue,
102
- message: title,
103
- count,
104
- description: processedMarkdown,
105
- };
106
- }
107
65
  // DevTools CDP errors can get noisy.
108
66
  DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
109
67
  DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({
@@ -115,6 +73,11 @@ DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({
115
73
  },
116
74
  });
117
75
  DevTools.I18n.i18n.registerLocaleDataForTest('en-US', {});
76
+ DevTools.Formatter.FormatterWorkerPool.FormatterWorkerPool.instance({
77
+ forceNew: true,
78
+ entrypointURL: import.meta
79
+ .resolve('./third_party/devtools-formatter-worker.js'),
80
+ });
118
81
  export class UniverseManager {
119
82
  #browser;
120
83
  #createUniverseFor;
@@ -206,3 +169,57 @@ const SKIP_ALL_PAUSES = {
206
169
  // Do nothing.
207
170
  },
208
171
  };
172
+ export async function createStackTraceForConsoleMessage(devTools, consoleMessage) {
173
+ const message = consoleMessage;
174
+ const rawStackTrace = message._rawStackTrace();
175
+ if (!rawStackTrace) {
176
+ return undefined;
177
+ }
178
+ const targetManager = devTools.universe.context.get(DevTools.TargetManager);
179
+ const messageTargetId = message._targetId();
180
+ const target = messageTargetId
181
+ ? targetManager.targetById(messageTargetId) || devTools.target
182
+ : devTools.target;
183
+ const model = target.model(DevTools.DebuggerModel);
184
+ // DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send
185
+ // an update event once a source map was attached and the stack trace retranslated. This doesn't
186
+ // work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map
187
+ // loads before creating the stack trace. We might also have to wait for Debugger.ScriptParsed events if
188
+ // the stack trace is created particularly early.
189
+ const scriptIds = new Set();
190
+ for (const frame of rawStackTrace.callFrames) {
191
+ scriptIds.add(frame.scriptId);
192
+ }
193
+ for (let asyncStack = rawStackTrace.parent; asyncStack; asyncStack = asyncStack.parent) {
194
+ for (const frame of asyncStack.callFrames) {
195
+ scriptIds.add(frame.scriptId);
196
+ }
197
+ }
198
+ const signal = AbortSignal.timeout(1_000);
199
+ await Promise.all([...scriptIds].map(id => waitForScript(model, id, signal)
200
+ .then(script => model.sourceMapManager().sourceMapForClientPromise(script))
201
+ .catch()));
202
+ const binding = devTools.universe.context.get(DevTools.DebuggerWorkspaceBinding);
203
+ // DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe.
204
+ return binding.createStackTraceFromProtocolRuntime(rawStackTrace, target);
205
+ }
206
+ // Waits indefinitely for the script so pair it with Promise.race.
207
+ async function waitForScript(model, scriptId, signal) {
208
+ while (true) {
209
+ if (signal.aborted) {
210
+ throw signal.reason;
211
+ }
212
+ const script = model.scriptForId(scriptId);
213
+ if (script) {
214
+ return script;
215
+ }
216
+ await new Promise((resolve, reject) => {
217
+ signal.addEventListener('abort', () => reject(signal.reason), {
218
+ once: true,
219
+ });
220
+ void model
221
+ .once('ParsedScriptSource')
222
+ .then(resolve);
223
+ });
224
+ }
225
+ }
@@ -6,12 +6,13 @@
6
6
  import fs from 'node:fs/promises';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
- import { extractUrlLikeFromDevToolsTitle, urlsEqual } from './DevtoolsUtils.js';
9
+ import { extractUrlLikeFromDevToolsTitle, UniverseManager, urlsEqual, } from './DevtoolsUtils.js';
10
10
  import { NetworkCollector, ConsoleCollector } from './PageCollector.js';
11
11
  import { Locator } from './third_party/index.js';
12
12
  import { listPages } from './tools/pages.js';
13
13
  import { takeSnapshot } from './tools/snapshot.js';
14
14
  import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
15
+ import { ExtensionRegistry, } from './utils/ExtensionRegistry.js';
15
16
  import { WaitForHelper } from './WaitForHelper.js';
16
17
  const DEFAULT_TIMEOUT = 5_000;
17
18
  const NAVIGATION_TIMEOUT = 10_000;
@@ -51,15 +52,22 @@ export class McpContext {
51
52
  #textSnapshot = null;
52
53
  #networkCollector;
53
54
  #consoleCollector;
55
+ #devtoolsUniverseManager;
56
+ #extensionRegistry = new ExtensionRegistry();
54
57
  #isRunningTrace = false;
55
58
  #networkConditionsMap = new WeakMap();
56
59
  #cpuThrottlingRateMap = new WeakMap();
57
60
  #geolocationMap = new WeakMap();
61
+ #viewportMap = new WeakMap();
62
+ #userAgentMap = new WeakMap();
58
63
  #dialog;
64
+ #pageIdMap = new WeakMap();
65
+ #nextPageId = 1;
59
66
  #nextSnapshotId = 1;
60
67
  #traceResults = [];
61
68
  #locatorClass;
62
69
  #options;
70
+ #uniqueBackendNodeIdToMcpId = new Map();
63
71
  constructor(browser, logger, options, locatorClass) {
64
72
  this.browser = browser;
65
73
  this.logger = logger;
@@ -86,15 +94,18 @@ export class McpContext {
86
94
  },
87
95
  };
88
96
  });
97
+ this.#devtoolsUniverseManager = new UniverseManager(this.browser);
89
98
  }
90
99
  async #init() {
91
100
  const pages = await this.createPagesSnapshot();
92
101
  await this.#networkCollector.init(pages);
93
102
  await this.#consoleCollector.init(pages);
103
+ await this.#devtoolsUniverseManager.init(pages);
94
104
  }
95
105
  dispose() {
96
106
  this.#networkCollector.dispose();
97
107
  this.#consoleCollector.dispose();
108
+ this.#devtoolsUniverseManager.dispose();
98
109
  }
99
110
  static async from(browser, logger, opts,
100
111
  /* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
@@ -149,25 +160,28 @@ export class McpContext {
149
160
  const page = this.getSelectedPage();
150
161
  return this.#consoleCollector.getData(page, includePreservedMessages);
151
162
  }
163
+ getDevToolsUniverse() {
164
+ return this.#devtoolsUniverseManager.get(this.getSelectedPage());
165
+ }
152
166
  getConsoleMessageStableId(message) {
153
167
  return this.#consoleCollector.getIdForResource(message);
154
168
  }
155
169
  getConsoleMessageById(id) {
156
170
  return this.#consoleCollector.getById(this.getSelectedPage(), id);
157
171
  }
158
- async newPage() {
159
- const page = await this.browser.newPage();
172
+ async newPage(background) {
173
+ const page = await this.browser.newPage({ background });
160
174
  await this.createPagesSnapshot();
161
175
  this.selectPage(page);
162
176
  this.#networkCollector.addPage(page);
163
177
  this.#consoleCollector.addPage(page);
164
178
  return page;
165
179
  }
166
- async closePage(pageIdx) {
180
+ async closePage(pageId) {
167
181
  if (this.#pages.length === 1) {
168
182
  throw new Error(CLOSE_PAGE_ERROR);
169
183
  }
170
- const page = this.getPageByIdx(pageIdx);
184
+ const page = this.getPageById(pageId);
171
185
  await page.close({ runBeforeUnload: false });
172
186
  }
173
187
  getNetworkRequestById(reqid) {
@@ -209,6 +223,32 @@ export class McpContext {
209
223
  const page = this.getSelectedPage();
210
224
  return this.#geolocationMap.get(page) ?? null;
211
225
  }
226
+ setViewport(viewport) {
227
+ const page = this.getSelectedPage();
228
+ if (viewport === null) {
229
+ this.#viewportMap.delete(page);
230
+ }
231
+ else {
232
+ this.#viewportMap.set(page, viewport);
233
+ }
234
+ }
235
+ getViewport() {
236
+ const page = this.getSelectedPage();
237
+ return this.#viewportMap.get(page) ?? null;
238
+ }
239
+ setUserAgent(userAgent) {
240
+ const page = this.getSelectedPage();
241
+ if (userAgent === null) {
242
+ this.#userAgentMap.delete(page);
243
+ }
244
+ else {
245
+ this.#userAgentMap.set(page, userAgent);
246
+ }
247
+ }
248
+ getUserAgent() {
249
+ const page = this.getSelectedPage();
250
+ return this.#userAgentMap.get(page) ?? null;
251
+ }
212
252
  setIsRunningPerformanceTrace(x) {
213
253
  this.#isRunningTrace = x;
214
254
  }
@@ -231,14 +271,16 @@ export class McpContext {
231
271
  }
232
272
  return page;
233
273
  }
234
- getPageByIdx(idx) {
235
- const pages = this.#pages;
236
- const page = pages[idx];
274
+ getPageById(pageId) {
275
+ const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
237
276
  if (!page) {
238
277
  throw new Error('No page found');
239
278
  }
240
279
  return page;
241
280
  }
281
+ getPageId(page) {
282
+ return this.#pageIdMap.get(page);
283
+ }
242
284
  #dialogHandler = (dialog) => {
243
285
  this.#dialog = dialog;
244
286
  };
@@ -283,25 +325,34 @@ export class McpContext {
283
325
  if (!this.#textSnapshot?.idToNode.size) {
284
326
  throw new Error(`No snapshot found. Use ${takeSnapshot.name} to capture one.`);
285
327
  }
286
- const [snapshotId] = uid.split('_');
287
- if (this.#textSnapshot.snapshotId !== snapshotId) {
288
- throw new Error('This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot.');
289
- }
290
328
  const node = this.#textSnapshot?.idToNode.get(uid);
291
329
  if (!node) {
292
- throw new Error('No such element found in the snapshot');
330
+ throw new Error('No such element found in the snapshot.');
331
+ }
332
+ const message = `Element with uid ${uid} no longer exists on the page.`;
333
+ try {
334
+ const handle = await node.elementHandle();
335
+ if (!handle) {
336
+ throw new Error(message);
337
+ }
338
+ return handle;
293
339
  }
294
- const handle = await node.elementHandle();
295
- if (!handle) {
296
- throw new Error('No such element found in the snapshot');
340
+ catch (error) {
341
+ throw new Error(message, {
342
+ cause: error,
343
+ });
297
344
  }
298
- return handle;
299
345
  }
300
346
  /**
301
347
  * Creates a snapshot of the pages.
302
348
  */
303
349
  async createPagesSnapshot() {
304
350
  const allPages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
351
+ for (const page of allPages) {
352
+ if (!this.#pageIdMap.has(page)) {
353
+ this.#pageIdMap.set(page, this.#nextPageId++);
354
+ }
355
+ }
305
356
  this.#pages = allPages.filter(page => {
306
357
  // If we allow debugging DevTools windows, return all pages.
307
358
  // If we are in regular mode, the user should only see non-DevTools page.
@@ -396,10 +447,24 @@ export class McpContext {
396
447
  // will be used for the tree serialization and mapping ids back to nodes.
397
448
  let idCounter = 0;
398
449
  const idToNode = new Map();
450
+ const seenUniqueIds = new Set();
399
451
  const assignIds = (node) => {
452
+ let id = '';
453
+ // @ts-expect-error untyped loaderId & backendNodeId.
454
+ const uniqueBackendId = `${node.loaderId}_${node.backendNodeId}`;
455
+ if (this.#uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
456
+ // Re-use MCP exposed ID if the uniqueId is the same.
457
+ id = this.#uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
458
+ }
459
+ else {
460
+ // Only generate a new ID if we have not seen the node before.
461
+ id = `${snapshotId}_${idCounter++}`;
462
+ this.#uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
463
+ }
464
+ seenUniqueIds.add(uniqueBackendId);
400
465
  const nodeWithId = {
401
466
  ...node,
402
- id: `${snapshotId}_${idCounter++}`,
467
+ id,
403
468
  children: node.children
404
469
  ? node.children.map(child => assignIds(child))
405
470
  : [],
@@ -428,6 +493,12 @@ export class McpContext {
428
493
  this.#textSnapshot.hasSelectedElement = true;
429
494
  this.#textSnapshot.selectedElementUid = this.resolveCdpElementId(data?.cdpBackendNodeId);
430
495
  }
496
+ // Clean up unique IDs that we did not see anymore.
497
+ for (const key of this.#uniqueBackendNodeIdToMcpId.keys()) {
498
+ if (!seenUniqueIds.has(key)) {
499
+ this.#uniqueBackendNodeIdToMcpId.delete(key);
500
+ }
501
+ }
431
502
  }
432
503
  getTextSnapshot() {
433
504
  return this.#textSnapshot;
@@ -456,6 +527,8 @@ export class McpContext {
456
527
  }
457
528
  }
458
529
  storeTraceRecording(result) {
530
+ // Clear the trace results because we only consume the latest trace currently.
531
+ this.#traceResults = [];
459
532
  this.#traceResults.push(result);
460
533
  }
461
534
  recordedTraces() {
@@ -502,4 +575,19 @@ export class McpContext {
502
575
  });
503
576
  await this.#networkCollector.init(await this.browser.pages());
504
577
  }
578
+ async installExtension(extensionPath) {
579
+ const id = await this.browser.installExtension(extensionPath);
580
+ await this.#extensionRegistry.registerExtension(id, extensionPath);
581
+ return id;
582
+ }
583
+ async uninstallExtension(id) {
584
+ await this.browser.uninstallExtension(id);
585
+ this.#extensionRegistry.remove(id);
586
+ }
587
+ listExtensions() {
588
+ return this.#extensionRegistry.list();
589
+ }
590
+ getExtension(id) {
591
+ return this.#extensionRegistry.getById(id);
592
+ }
505
593
  }