opera-devtools-mcp 0.6.1 → 0.8.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 (96) hide show
  1. package/README.md +1 -1
  2. package/build/src/McpContext.js +78 -41
  3. package/build/src/McpPage.js +5 -1
  4. package/build/src/ToolHandler.js +54 -76
  5. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  6. package/build/src/bin/chrome-devtools.js +56 -124
  7. package/build/src/bin/opera-browser-cli.js +102 -0
  8. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  9. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  10. package/build/src/bin/opera-devtools-mcp.js +20 -1
  11. package/build/src/browser.js +23 -25
  12. package/build/src/config/browser-options.js +126 -0
  13. package/build/src/config/category-options.js +81 -0
  14. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  15. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  16. package/build/src/daemon/client.js +55 -40
  17. package/build/src/daemon/daemon.js +62 -39
  18. package/build/src/daemon/utils.js +6 -0
  19. package/build/src/devtools/DevtoolsUtils.js +27 -21
  20. package/build/src/formatters/NetworkFormatter.js +5 -2
  21. package/build/src/index.js +166 -100
  22. package/build/src/opera/branding.js +4 -2
  23. package/build/src/opera/browserActivity.js +62 -0
  24. package/build/src/opera/browserCleanup.js +123 -0
  25. package/build/src/opera/browserErrors.js +66 -0
  26. package/build/src/opera/browserFlags.js +184 -38
  27. package/build/src/opera/browserTarget.js +513 -0
  28. package/build/src/opera/cdpErrors.js +391 -0
  29. package/build/src/opera/cliCommands.js +378 -0
  30. package/build/src/opera/cliOutput.js +284 -0
  31. package/build/src/opera/compactSnapshot.js +525 -0
  32. package/build/src/opera/config.js +166 -0
  33. package/build/src/opera/daemonLifecycle.js +257 -0
  34. package/build/src/opera/daemonLog.js +103 -0
  35. package/build/src/opera/daemonPidFile.js +83 -0
  36. package/build/src/opera/daemonShutdown.js +66 -0
  37. package/build/src/opera/daemonSocket.js +87 -0
  38. package/build/src/opera/daemonStreaming.js +130 -0
  39. package/build/src/opera/daemonToolCall.js +26 -0
  40. package/build/src/opera/detect.js +114 -0
  41. package/build/src/opera/doctor.js +317 -0
  42. package/build/src/opera/envConfig.js +229 -0
  43. package/build/src/opera/launcherNotice.js +116 -0
  44. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  45. package/build/src/opera/logs.js +133 -0
  46. package/build/src/opera/mcpServerSupervisor.js +128 -0
  47. package/build/src/opera/migrationShared.js +164 -0
  48. package/build/src/opera/operaPages.js +56 -0
  49. package/build/src/opera/pageIdRouting.js +35 -0
  50. package/build/src/opera/pageRecovery.js +53 -0
  51. package/build/src/opera/profile.js +270 -0
  52. package/build/src/opera/refArgs.js +36 -0
  53. package/build/src/opera/serviceWorkerRetry.js +46 -4
  54. package/build/src/opera/setup.js +290 -0
  55. package/build/src/opera/skills/SKILL.md +160 -0
  56. package/build/src/opera/streamingTools.js +73 -0
  57. package/build/src/opera/suggestions.js +67 -0
  58. package/build/src/opera/toolHandlerHooks.js +25 -1
  59. package/build/src/opera/tools/opera.js +107 -38
  60. package/build/src/opera/urlResolver.js +69 -0
  61. package/build/src/opera/webStorageWarning.js +92 -0
  62. package/build/src/processors/HeapSnapshotManager.js +12 -0
  63. package/build/src/telemetry/ClearcutLogger.js +19 -6
  64. package/build/src/telemetry/transformation.js +4 -0
  65. package/build/src/telemetry/types.js +4 -0
  66. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  67. package/build/src/third_party/bundled-packages.json +3 -3
  68. package/build/src/third_party/devtools-formatter-worker.js +23 -0
  69. package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
  70. package/build/src/third_party/index.js +15460 -14256
  71. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  72. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  73. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  74. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  75. package/build/src/tools/ToolDefinition.js +15 -0
  76. package/build/src/tools/categories.js +0 -6
  77. package/build/src/tools/comments.js +90 -0
  78. package/build/src/tools/console.js +1 -1
  79. package/build/src/tools/emulation.js +1 -1
  80. package/build/src/tools/extensions.js +1 -1
  81. package/build/src/tools/memory.js +60 -6
  82. package/build/src/tools/network.js +2 -2
  83. package/build/src/tools/pages.js +26 -15
  84. package/build/src/tools/performance.js +4 -3
  85. package/build/src/tools/screencast.js +3 -2
  86. package/build/src/tools/screenshot.js +39 -8
  87. package/build/src/tools/script.js +17 -4
  88. package/build/src/tools/slim/tools.js +41 -33
  89. package/build/src/tools/snapshot.js +1 -1
  90. package/build/src/tools/tools.js +2 -0
  91. package/build/src/utils/WaitForHelper.js +12 -1
  92. package/build/src/utils/bytes.js +105 -0
  93. package/build/src/utils/url.js +79 -0
  94. package/build/src/version.js +1 -1
  95. package/package.json +29 -8
  96. package/build/src/bin/opera-devtools.js +0 -10
package/README.md CHANGED
@@ -10,7 +10,7 @@ control and inspect a live browser. It acts as a Model-Context-Protocol
10
10
  DevTools for reliable automation, in-depth debugging, and performance analysis.
11
11
  When connected to Opera Neon, it also exposes Opera's built-in AI capabilities.
12
12
 
13
- ## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
13
+ ## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md) | [Stress testing](./docs/stress-testing.md)
14
14
 
15
15
  ## Key features
16
16
 
@@ -15,6 +15,7 @@ import { Locator, } from './third_party/index.js';
15
15
  import { listPages } from './tools/pages.js';
16
16
  import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
17
17
  import { getTempFilePath, resolveCanonicalPath } from './utils/files.js';
18
+ import { isAllowedUrl } from './utils/url.js';
18
19
  // Page ids are handed out from a process-wide counter so they stay unique
19
20
  // across all contexts, in particular across browser reconnects. An id issued
20
21
  // before a reconnect then fails to resolve instead of hitting an unrelated
@@ -81,6 +82,12 @@ export class McpContext {
81
82
  }
82
83
  #onTargetCreated = async (target) => {
83
84
  try {
85
+ const url = target.url();
86
+ if (!isAllowedUrl(url, {
87
+ categoryExtensions: this.#options.categoryExtensions,
88
+ })) {
89
+ return;
90
+ }
84
91
  const page = await target.page();
85
92
  if (!page) {
86
93
  return;
@@ -137,28 +144,30 @@ export class McpContext {
137
144
  }
138
145
  async validatePath(filePath) {
139
146
  if (filePath === undefined) {
140
- return;
147
+ return undefined;
148
+ }
149
+ let canonicalPath;
150
+ try {
151
+ canonicalPath = await resolveCanonicalPath(filePath);
152
+ }
153
+ catch (err) {
154
+ const errMsg = err instanceof Error ? err.message : String(err);
155
+ console.error(`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`);
156
+ throw new Error(`Access denied: Cannot resolve base path for ${filePath}.`);
141
157
  }
142
158
  // If the client never negotiated roots and the operator has explicitly
143
159
  // opted into unrestricted access via --allow-unrestricted-paths, restore
144
160
  // the previous permissive behavior and skip validation.
145
161
  if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
146
- return;
162
+ // Canonical path might not exist yet so we fallback to
163
+ // path.resolve(filePath). Consumers should not follow symlinks.
164
+ return canonicalPath || path.resolve(filePath);
147
165
  }
148
166
  // roots() always returns at least the temp directory, even if the
149
167
  // connecting client never negotiated the optional `roots` capability.
150
168
  // Path validation must not be skipped just because no workspace roots
151
169
  // were configured.
152
170
  const roots = this.roots();
153
- let canonicalPath;
154
- try {
155
- canonicalPath = await resolveCanonicalPath(filePath);
156
- }
157
- catch (err) {
158
- const errMsg = err instanceof Error ? err.message : String(err);
159
- console.error(`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`);
160
- throw new Error(`Access denied: Cannot resolve base path for ${filePath}.`);
161
- }
162
171
  let allowed = false;
163
172
  const resolvedRoots = await Promise.allSettled(roots.map(async (root) => {
164
173
  const rootPathUri = root.uri;
@@ -186,12 +195,12 @@ export class McpContext {
186
195
  if (!allowed) {
187
196
  throw new Error(`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`);
188
197
  }
198
+ return canonicalPath || path.resolve(filePath);
189
199
  }
190
200
  async ensureExtension(filePath, extension) {
191
- const resolvedPath = path.resolve(filePath);
192
- const currentExtension = path.extname(resolvedPath);
193
- const outputPath = `${resolvedPath.slice(0, resolvedPath.length - currentExtension.length)}${extension}`;
194
- await this.validatePath(outputPath);
201
+ const resolved = await this.validatePath(filePath);
202
+ const currentExtension = path.extname(resolved);
203
+ const outputPath = `${resolved.slice(0, resolved.length - currentExtension.length)}${extension}`;
195
204
  return outputPath;
196
205
  }
197
206
  async newPage(background, isolatedContextName) {
@@ -202,7 +211,7 @@ export class McpContext {
202
211
  ctx = await this.browser.createBrowserContext();
203
212
  this.#isolatedContexts.set(isolatedContextName, ctx);
204
213
  }
205
- page = await ctx.newPage();
214
+ page = await ctx.newPage({ background });
206
215
  }
207
216
  else {
208
217
  page = await this.browser.newPage({ background });
@@ -266,6 +275,21 @@ export class McpContext {
266
275
  }
267
276
  return page;
268
277
  }
278
+ getSelectedMcpPageUrl(page) {
279
+ let targetPage = page;
280
+ if (!targetPage) {
281
+ try {
282
+ targetPage = this.getSelectedMcpPage();
283
+ }
284
+ catch {
285
+ return undefined;
286
+ }
287
+ }
288
+ if (targetPage?.pptrPage?.isClosed() === false) {
289
+ return targetPage.pptrPage.url();
290
+ }
291
+ return undefined;
292
+ }
269
293
  async getDevToolsData(page) {
270
294
  const targetPage = page ?? this.#selectedPage;
271
295
  if (!targetPage) {
@@ -372,6 +396,7 @@ export class McpContext {
372
396
  hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
373
397
  isolatedContextName: this.#getBrowserContextToNameMap().get(page.browserContext()),
374
398
  navigationTimeout: this.#options.navigationTimeout,
399
+ sourceMaps: this.#options.sourceMaps,
375
400
  });
376
401
  this.#mcpPages.set(page, mcpPage);
377
402
  await mcpPage.init();
@@ -409,29 +434,38 @@ export class McpContext {
409
434
  }
410
435
  async #fetchBrowserPages() {
411
436
  const allPages = (await this.browser.pages(this.#options.experimentalIncludeAllPages)).filter(page => {
412
- return (this.#options.experimentalDevToolsDebugging ||
413
- !page.url().startsWith('devtools://'));
414
- });
415
- const allTargets = this.browser.targets();
416
- const extensionTargets = allTargets.filter(target => {
417
- return (target.url().startsWith('chrome-extension://') &&
418
- target.type() === 'page');
437
+ if (!this.#options.experimentalDevToolsDebugging &&
438
+ page.url().startsWith('devtools://')) {
439
+ return false;
440
+ }
441
+ return isAllowedUrl(page.url(), {
442
+ categoryExtensions: this.#options.categoryExtensions,
443
+ });
419
444
  });
420
- await Promise.allSettled(extensionTargets.map(async (target) => {
421
- try {
422
- let page = await target.page();
423
- if (!page) {
424
- page = await target.asPage();
445
+ if (this.#options.categoryExtensions) {
446
+ const allTargets = this.browser.targets();
447
+ const extensionTargets = allTargets.filter(target => {
448
+ return (target.url().startsWith('chrome-extension://') &&
449
+ target.type() === 'page');
450
+ });
451
+ await Promise.allSettled(extensionTargets.map(async (target) => {
452
+ try {
453
+ let page = await target.page();
454
+ if (!page) {
455
+ page = await target.asPage();
456
+ }
457
+ this.#extensionPages.set(target, page);
458
+ if (page &&
459
+ isAllowedUrl(page.url(), { categoryExtensions: true }) &&
460
+ !allPages.includes(page)) {
461
+ allPages.push(page);
462
+ }
425
463
  }
426
- this.#extensionPages.set(target, page);
427
- if (page && !allPages.includes(page)) {
428
- allPages.push(page);
464
+ catch (e) {
465
+ this.logger?.('Failed to get page for extension target', e);
429
466
  }
430
- }
431
- catch (e) {
432
- this.logger?.('Failed to get page for extension target', e);
433
- }
434
- }));
467
+ }));
468
+ }
435
469
  return allPages;
436
470
  }
437
471
  getExtensionServiceWorkers() {
@@ -441,16 +475,16 @@ export class McpContext {
441
475
  return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
442
476
  }
443
477
  async #writeFile(filepath, data) {
444
- await this.validatePath(filepath);
478
+ const resolved = await this.validatePath(filepath);
445
479
  try {
446
- await fs.mkdir(path.dirname(filepath), { recursive: true });
480
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
447
481
  // Open the file with flags to:
448
482
  // - O_WRONLY: Write-only
449
483
  // - O_CREAT: Create if it doesn't exist
450
484
  // - O_TRUNC: Truncate to zero length if it exists
451
485
  // - O_NOFOLLOW: DO NOT follow symlinks.
452
486
  // - 0o600: Permissions: read/write for owner, no permissions for others.
453
- await fs.writeFile(filepath, data, {
487
+ await fs.writeFile(resolved, data, {
454
488
  flag: fs.constants.O_WRONLY |
455
489
  fs.constants.O_CREAT |
456
490
  fs.constants.O_TRUNC |
@@ -515,6 +549,9 @@ export class McpContext {
515
549
  async getHeapSnapshotDuplicateStrings(filePath) {
516
550
  return await this.#heapSnapshotManager.getDuplicateStrings(filePath);
517
551
  }
552
+ async queryHeapSnapshotObjects(filePath, options) {
553
+ return await this.#heapSnapshotManager.queryObjects(filePath, options);
554
+ }
518
555
  async getHeapSnapshotStats(filePath) {
519
556
  return await this.#heapSnapshotManager.getStats(filePath);
520
557
  }
@@ -585,8 +622,8 @@ export class McpContext {
585
622
  return response.text();
586
623
  }
587
624
  case 'file:': {
588
- await this.validatePath(fileURLToPath(url));
589
- return await fs.readFile(url, 'utf-8');
625
+ const resolved = await this.validatePath(fileURLToPath(url));
626
+ return await fs.readFile(resolved, 'utf-8');
590
627
  }
591
628
  default:
592
629
  throw new Error(`Unsupported protocol for: ${url}`);
@@ -139,10 +139,12 @@ export class McpPage {
139
139
  #hasNetworkBlockOrAllowlist;
140
140
  #locatorClass;
141
141
  #navigationTimeout;
142
+ #sourceMaps;
142
143
  constructor(page, id, options) {
143
144
  this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist;
144
145
  this.#locatorClass = options.locatorClass;
145
146
  this.#navigationTimeout = options.navigationTimeout ?? NAVIGATION_TIMEOUT;
147
+ this.#sourceMaps = options.sourceMaps ?? true;
146
148
  this.pptrPage = page;
147
149
  this.id = id;
148
150
  this.isolatedContextName = options.isolatedContextName;
@@ -183,7 +185,9 @@ export class McpPage {
183
185
  }
184
186
  try {
185
187
  const session = await this.pptrPage.createCDPSession();
186
- this.#devtoolsUniverse = await createTargetUniverse(session);
188
+ this.#devtoolsUniverse = await createTargetUniverse(session, {
189
+ sourceMaps: this.#sourceMaps,
190
+ });
187
191
  }
188
192
  catch (e) {
189
193
  logger?.('Failed to initialize DevTools universe', e);
@@ -5,71 +5,44 @@
5
5
  *
6
6
  * Modified by Opera Software AS: optional `hooks` seam (see
7
7
  * ./opera/toolHandlerHooks.ts) for mutex bypass, browser relaunch and log
8
- * streaming. Keep the diff to the three `this.hooks?.` call sites.
8
+ * streaming, plus one page-resolution call that tolerates a selection the user
9
+ * closed (see ./opera/pageRecovery.ts). Keep the diff to the three
10
+ * `this.hooks?.` call sites and that line.
9
11
  */
10
12
  import { McpResponse } from './McpResponse.js';
11
13
  import { CLI_BIN_NAME } from './opera/branding.js';
14
+ import { resolveSelectedPage } from './opera/pageRecovery.js';
12
15
  import { SlimMcpResponse } from './SlimMcpResponse.js';
13
16
  import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
14
- import { bucketizeLatency, buildContext } from './telemetry/transformation.js';
15
17
  import { zod } from './third_party/index.js';
16
- import { labels, OFF_BY_DEFAULT_CATEGORIES } from './tools/categories.js';
18
+ import { labels } from './tools/categories.js';
19
+ import { categoryToFlagName } from './config/category-options.js';
17
20
  import { pageIdSchema } from './tools/ToolDefinition.js';
18
21
  import { logger } from './utils/logger.js';
19
- import { fileURLToPath } from 'node:url';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
23
  import { isLocalhost } from './utils/url.js';
21
- export function buildFlag(category) {
22
- return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
23
- }
24
24
  function buildDisabledMessage(toolName, flag, categoryLabel) {
25
25
  const reason = categoryLabel
26
26
  ? `is in category ${categoryLabel} which`
27
- : `requires experimental feature ${flag} and`;
27
+ : `requires ${flag.startsWith('--experimental') ? 'experimental feature' : 'flag'} ${flag} and`;
28
28
  return `Tool ${toolName} ${reason} is currently disabled. Enable it by running ${CLI_BIN_NAME} start ${flag}=true. For more information check the README.`;
29
29
  }
30
- function getCategoryStatus(category, serverArgs) {
31
- const categoryFlag = buildFlag(category);
32
- const flagValue = serverArgs[categoryFlag];
33
- const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category)
34
- ? !flagValue
35
- : flagValue === false;
36
- if (isDisabled) {
37
- return {
38
- categoryFlag,
39
- disabled: true,
40
- };
41
- }
42
- return {
43
- disabled: false,
44
- };
45
- }
46
- function getConditionStatus(condition, serverArgs) {
47
- if (condition && !serverArgs[condition]) {
48
- return { conditionFlag: condition, disabled: true };
49
- }
50
- return { disabled: false };
51
- }
52
30
  function getToolStatusInfo(tool, serverArgs) {
53
31
  const category = tool.annotations.category;
54
- const categoryCheck = getCategoryStatus(category, serverArgs);
55
- if (category && categoryCheck.disabled) {
56
- if (!categoryCheck.categoryFlag) {
57
- throw new Error('when the category is disabled there should always be a flag set');
32
+ if (category) {
33
+ const flag = categoryToFlagName(category);
34
+ if (!serverArgs[flag]) {
35
+ return {
36
+ disabled: true,
37
+ reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]),
38
+ };
58
39
  }
59
- return {
60
- disabled: true,
61
- reason: buildDisabledMessage(tool.name, `--${categoryCheck.categoryFlag}`, labels[category]),
62
- };
63
40
  }
64
41
  for (const condition of tool.annotations.conditions || []) {
65
- const conditionCheck = getConditionStatus(condition, serverArgs);
66
- if (conditionCheck.disabled) {
67
- if (!conditionCheck.conditionFlag) {
68
- throw new Error('when the condition is disabled there should always be a flag set');
69
- }
42
+ if (!serverArgs[condition]) {
70
43
  return {
71
44
  disabled: true,
72
- reason: buildDisabledMessage(tool.name, `--${conditionCheck.conditionFlag}`),
45
+ reason: buildDisabledMessage(tool.name, `--${condition}`),
73
46
  };
74
47
  }
75
48
  }
@@ -89,14 +62,20 @@ function buildUnknownArgumentsMessage(toolName, unknownArgumentNames, expectedAr
89
62
  const correction = unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them';
90
63
  return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`;
91
64
  }
92
- function extractPaths(value) {
93
- if (typeof value === 'string') {
94
- return [value];
65
+ async function validateAndResolvePathOrUrl(filePathOrUrl, context) {
66
+ try {
67
+ const url = new URL(filePathOrUrl);
68
+ if (url.protocol === 'file:') {
69
+ return pathToFileURL(await context.validatePath(fileURLToPath(url))).href;
70
+ }
71
+ else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
72
+ return filePathOrUrl;
73
+ }
95
74
  }
96
- if (Array.isArray(value)) {
97
- return value.filter(item => typeof item === 'string');
75
+ catch {
76
+ // Suppress parsing errors for regular file paths.
98
77
  }
99
- return [];
78
+ return await context.validatePath(filePathOrUrl);
100
79
  }
101
80
  function isLocalBrowser(context) {
102
81
  if (context.browser.process()) {
@@ -122,27 +101,25 @@ function shouldValidateFile(option, isLocal) {
122
101
  }
123
102
  async function validateToolFiles(tool, params, context) {
124
103
  const isLocal = isLocalBrowser(context);
125
- const pathsOrUrlsToValidate = [];
126
104
  for (const [key, option] of Object.entries(tool.verifyFilesSchema)) {
127
105
  if (shouldValidateFile(option, isLocal)) {
128
- pathsOrUrlsToValidate.push(...extractPaths(params[key]));
129
- }
130
- }
131
- for (const filePathOrUrl of pathsOrUrlsToValidate) {
132
- let filePath = filePathOrUrl;
133
- try {
134
- const url = new URL(filePathOrUrl);
135
- if (url.protocol === 'file:') {
136
- filePath = fileURLToPath(url);
106
+ const val = params[key];
107
+ if (typeof val === 'string') {
108
+ params[key] = await validateAndResolvePathOrUrl(val, context);
137
109
  }
138
- else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
139
- continue;
110
+ else if (Array.isArray(val)) {
111
+ const updated = [];
112
+ for (const item of val) {
113
+ if (typeof item === 'string') {
114
+ updated.push(await validateAndResolvePathOrUrl(item, context));
115
+ }
116
+ else {
117
+ throw new Error('Unexpected non-string value as a file path or URL');
118
+ }
119
+ }
120
+ params[key] = updated;
140
121
  }
141
122
  }
142
- catch {
143
- // Suppress parsing errors for regular file paths.
144
- }
145
- await context.validatePath(filePath);
146
123
  }
147
124
  }
148
125
  export class ToolHandler {
@@ -167,7 +144,7 @@ export class ToolHandler {
167
144
  this.inputSchema =
168
145
  'pageScoped' in tool &&
169
146
  tool.pageScoped &&
170
- serverArgs.experimentalPageIdRouting &&
147
+ serverArgs.pageIdRouting &&
171
148
  !serverArgs.slim
172
149
  ? { ...pageIdSchema, ...tool.schema }
173
150
  : tool.schema;
@@ -226,11 +203,11 @@ export class ToolHandler {
226
203
  if (isPageScopedTool(this.tool)) {
227
204
  const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
228
205
  page =
229
- this.serverArgs.experimentalPageIdRouting &&
206
+ this.serverArgs.pageIdRouting &&
230
207
  pageId !== undefined &&
231
208
  !this.serverArgs.slim
232
209
  ? context.getPageById(pageId)
233
- : context.getSelectedMcpPage();
210
+ : await resolveSelectedPage(context, response);
234
211
  response.setPage(page);
235
212
  if (this.tool.blockedByDialog) {
236
213
  page.throwIfDialogOpen();
@@ -252,10 +229,7 @@ export class ToolHandler {
252
229
  response.setError(err);
253
230
  }
254
231
  devToolsData = await context.getDevToolsData(page);
255
- const targetPage = page ?? context.getSelectedMcpPage();
256
- if (targetPage?.pptrPage?.isClosed() === false) {
257
- pageUrl = targetPage.pptrPage.url();
258
- }
232
+ pageUrl = context.getSelectedMcpPageUrl(page);
259
233
  // Resolve data format: --experimentalDataFormat takes precedence, fall back to legacy --experimentalToonFormat
260
234
  let dataFormat = 'default';
261
235
  if (this.serverArgs.experimentalDataFormat) {
@@ -294,14 +268,18 @@ export class ToolHandler {
294
268
  };
295
269
  }
296
270
  finally {
297
- const context = buildContext(devToolsData, pageUrl);
271
+ // Before telemetry and the mutex release: the claim `beforeInvoke` took
272
+ // has to be released even when the invocation failed, or the next Opera
273
+ // AI tool waits on a browser this one only looks like it is using.
274
+ this.hooks?.afterInvoke(this.tool);
298
275
  void ClearcutLogger.get()?.logToolInvocation({
299
276
  toolName: this.tool.name,
300
277
  params,
301
278
  schema: this.inputSchema,
302
279
  success,
303
- latencyMs: bucketizeLatency(Date.now() - startTime),
304
- context,
280
+ latencyMs: Date.now() - startTime,
281
+ devToolsData,
282
+ pageUrl,
305
283
  });
306
284
  guard?.[Symbol.dispose]();
307
285
  }
@@ -8,7 +8,7 @@
8
8
  import '../utils/polyfill.js';
9
9
  import process from 'node:process';
10
10
  import { closeBrowser } from '../browser.js';
11
- import { createMcpServer, logDisclaimers } from '../index.js';
11
+ import { McpServer, logDisclaimers } from '../index.js';
12
12
  import { ENV_CRASH_ON_UNCAUGHT, PACKAGE_NAME, PRODUCT_NAME, } from '../opera/branding.js';
13
13
  import { enforceTelemetryPolicy } from '../opera/policy.js';
14
14
  import { ClearcutLogger } from '../telemetry/ClearcutLogger.js';
@@ -17,7 +17,7 @@ import { StdioServerTransport } from '../third_party/index.js';
17
17
  import { checkForUpdates } from '../utils/check-for-updates.js';
18
18
  import { logger, saveLogsToFile } from '../utils/logger.js';
19
19
  import { VERSION } from '../version.js';
20
- import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
20
+ import { mcpOptions, parseArguments } from '../config/mcp-options.js';
21
21
  await checkForUpdates(`Run \`npm install ${PACKAGE_NAME}@latest\` to update.`);
22
22
  export const args = parseArguments(VERSION);
23
23
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
@@ -65,7 +65,7 @@ process.on('SIGINT', () => {
65
65
  process.on('SIGHUP', () => {
66
66
  void shutdown('SIGHUP');
67
67
  });
68
- const { server } = await createMcpServer(args, {
68
+ const server = await McpServer.from(args, {
69
69
  logFile,
70
70
  });
71
71
  const transport = new StdioServerTransport();
@@ -73,5 +73,5 @@ await server.connect(transport);
73
73
  logger?.(`${PRODUCT_NAME} connected`);
74
74
  logDisclaimers(args);
75
75
  void ClearcutLogger.get()?.logDailyActiveIfNeeded();
76
- void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, cliOptions));
76
+ void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, mcpOptions));
77
77
  //# sourceMappingURL=chrome-devtools-mcp-main.js.map