opera-devtools-mcp 0.6.1 → 0.7.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 (51) hide show
  1. package/build/src/McpContext.js +78 -41
  2. package/build/src/McpPage.js +5 -1
  3. package/build/src/ToolHandler.js +45 -74
  4. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  5. package/build/src/bin/chrome-devtools.js +29 -30
  6. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  7. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  8. package/build/src/browser.js +5 -16
  9. package/build/src/config/browser-options.js +126 -0
  10. package/build/src/config/category-options.js +81 -0
  11. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  12. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  13. package/build/src/daemon/client.js +9 -0
  14. package/build/src/daemon/utils.js +6 -0
  15. package/build/src/devtools/DevtoolsUtils.js +27 -21
  16. package/build/src/formatters/NetworkFormatter.js +5 -2
  17. package/build/src/index.js +166 -100
  18. package/build/src/processors/HeapSnapshotManager.js +12 -0
  19. package/build/src/telemetry/ClearcutLogger.js +19 -6
  20. package/build/src/telemetry/transformation.js +4 -0
  21. package/build/src/telemetry/types.js +4 -0
  22. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  23. package/build/src/third_party/bundled-packages.json +3 -3
  24. package/build/src/third_party/devtools-formatter-worker.js +22 -0
  25. package/build/src/third_party/devtools-heap-snapshot-worker.js +100 -20
  26. package/build/src/third_party/index.js +15459 -14256
  27. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  28. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  29. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  30. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  31. package/build/src/tools/ToolDefinition.js +15 -0
  32. package/build/src/tools/categories.js +0 -6
  33. package/build/src/tools/comments.js +90 -0
  34. package/build/src/tools/console.js +1 -1
  35. package/build/src/tools/emulation.js +1 -1
  36. package/build/src/tools/extensions.js +1 -1
  37. package/build/src/tools/memory.js +60 -6
  38. package/build/src/tools/network.js +2 -2
  39. package/build/src/tools/pages.js +26 -15
  40. package/build/src/tools/performance.js +4 -3
  41. package/build/src/tools/screencast.js +3 -2
  42. package/build/src/tools/screenshot.js +39 -8
  43. package/build/src/tools/script.js +17 -4
  44. package/build/src/tools/slim/tools.js +41 -33
  45. package/build/src/tools/snapshot.js +1 -1
  46. package/build/src/tools/tools.js +2 -0
  47. package/build/src/utils/WaitForHelper.js +12 -1
  48. package/build/src/utils/bytes.js +105 -0
  49. package/build/src/utils/url.js +73 -0
  50. package/build/src/version.js +1 -1
  51. package/package.json +23 -4
@@ -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);
@@ -11,65 +11,35 @@ import { McpResponse } from './McpResponse.js';
11
11
  import { CLI_BIN_NAME } from './opera/branding.js';
12
12
  import { SlimMcpResponse } from './SlimMcpResponse.js';
13
13
  import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
14
- import { bucketizeLatency, buildContext } from './telemetry/transformation.js';
15
14
  import { zod } from './third_party/index.js';
16
- import { labels, OFF_BY_DEFAULT_CATEGORIES } from './tools/categories.js';
15
+ import { labels } from './tools/categories.js';
16
+ import { categoryToFlagName } from './config/category-options.js';
17
17
  import { pageIdSchema } from './tools/ToolDefinition.js';
18
18
  import { logger } from './utils/logger.js';
19
- import { fileURLToPath } from 'node:url';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
  import { isLocalhost } from './utils/url.js';
21
- export function buildFlag(category) {
22
- return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
23
- }
24
21
  function buildDisabledMessage(toolName, flag, categoryLabel) {
25
22
  const reason = categoryLabel
26
23
  ? `is in category ${categoryLabel} which`
27
- : `requires experimental feature ${flag} and`;
24
+ : `requires ${flag.startsWith('--experimental') ? 'experimental feature' : 'flag'} ${flag} and`;
28
25
  return `Tool ${toolName} ${reason} is currently disabled. Enable it by running ${CLI_BIN_NAME} start ${flag}=true. For more information check the README.`;
29
26
  }
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
27
  function getToolStatusInfo(tool, serverArgs) {
53
28
  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');
29
+ if (category) {
30
+ const flag = categoryToFlagName(category);
31
+ if (!serverArgs[flag]) {
32
+ return {
33
+ disabled: true,
34
+ reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]),
35
+ };
58
36
  }
59
- return {
60
- disabled: true,
61
- reason: buildDisabledMessage(tool.name, `--${categoryCheck.categoryFlag}`, labels[category]),
62
- };
63
37
  }
64
38
  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
- }
39
+ if (!serverArgs[condition]) {
70
40
  return {
71
41
  disabled: true,
72
- reason: buildDisabledMessage(tool.name, `--${conditionCheck.conditionFlag}`),
42
+ reason: buildDisabledMessage(tool.name, `--${condition}`),
73
43
  };
74
44
  }
75
45
  }
@@ -89,14 +59,20 @@ function buildUnknownArgumentsMessage(toolName, unknownArgumentNames, expectedAr
89
59
  const correction = unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them';
90
60
  return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`;
91
61
  }
92
- function extractPaths(value) {
93
- if (typeof value === 'string') {
94
- return [value];
62
+ async function validateAndResolvePathOrUrl(filePathOrUrl, context) {
63
+ try {
64
+ const url = new URL(filePathOrUrl);
65
+ if (url.protocol === 'file:') {
66
+ return pathToFileURL(await context.validatePath(fileURLToPath(url))).href;
67
+ }
68
+ else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
69
+ return filePathOrUrl;
70
+ }
95
71
  }
96
- if (Array.isArray(value)) {
97
- return value.filter(item => typeof item === 'string');
72
+ catch {
73
+ // Suppress parsing errors for regular file paths.
98
74
  }
99
- return [];
75
+ return await context.validatePath(filePathOrUrl);
100
76
  }
101
77
  function isLocalBrowser(context) {
102
78
  if (context.browser.process()) {
@@ -122,27 +98,25 @@ function shouldValidateFile(option, isLocal) {
122
98
  }
123
99
  async function validateToolFiles(tool, params, context) {
124
100
  const isLocal = isLocalBrowser(context);
125
- const pathsOrUrlsToValidate = [];
126
101
  for (const [key, option] of Object.entries(tool.verifyFilesSchema)) {
127
102
  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);
103
+ const val = params[key];
104
+ if (typeof val === 'string') {
105
+ params[key] = await validateAndResolvePathOrUrl(val, context);
137
106
  }
138
- else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
139
- continue;
107
+ else if (Array.isArray(val)) {
108
+ const updated = [];
109
+ for (const item of val) {
110
+ if (typeof item === 'string') {
111
+ updated.push(await validateAndResolvePathOrUrl(item, context));
112
+ }
113
+ else {
114
+ throw new Error('Unexpected non-string value as a file path or URL');
115
+ }
116
+ }
117
+ params[key] = updated;
140
118
  }
141
119
  }
142
- catch {
143
- // Suppress parsing errors for regular file paths.
144
- }
145
- await context.validatePath(filePath);
146
120
  }
147
121
  }
148
122
  export class ToolHandler {
@@ -167,7 +141,7 @@ export class ToolHandler {
167
141
  this.inputSchema =
168
142
  'pageScoped' in tool &&
169
143
  tool.pageScoped &&
170
- serverArgs.experimentalPageIdRouting &&
144
+ serverArgs.pageIdRouting &&
171
145
  !serverArgs.slim
172
146
  ? { ...pageIdSchema, ...tool.schema }
173
147
  : tool.schema;
@@ -226,7 +200,7 @@ export class ToolHandler {
226
200
  if (isPageScopedTool(this.tool)) {
227
201
  const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
228
202
  page =
229
- this.serverArgs.experimentalPageIdRouting &&
203
+ this.serverArgs.pageIdRouting &&
230
204
  pageId !== undefined &&
231
205
  !this.serverArgs.slim
232
206
  ? context.getPageById(pageId)
@@ -252,10 +226,7 @@ export class ToolHandler {
252
226
  response.setError(err);
253
227
  }
254
228
  devToolsData = await context.getDevToolsData(page);
255
- const targetPage = page ?? context.getSelectedMcpPage();
256
- if (targetPage?.pptrPage?.isClosed() === false) {
257
- pageUrl = targetPage.pptrPage.url();
258
- }
229
+ pageUrl = context.getSelectedMcpPageUrl(page);
259
230
  // Resolve data format: --experimentalDataFormat takes precedence, fall back to legacy --experimentalToonFormat
260
231
  let dataFormat = 'default';
261
232
  if (this.serverArgs.experimentalDataFormat) {
@@ -294,14 +265,14 @@ export class ToolHandler {
294
265
  };
295
266
  }
296
267
  finally {
297
- const context = buildContext(devToolsData, pageUrl);
298
268
  void ClearcutLogger.get()?.logToolInvocation({
299
269
  toolName: this.tool.name,
300
270
  params,
301
271
  schema: this.inputSchema,
302
272
  success,
303
- latencyMs: bucketizeLatency(Date.now() - startTime),
304
- context,
273
+ latencyMs: Date.now() - startTime,
274
+ devToolsData,
275
+ pageUrl,
305
276
  });
306
277
  guard?.[Symbol.dispose]();
307
278
  }
@@ -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
@@ -14,35 +14,27 @@ import { CLI_BIN_NAME, MCP_BIN_NAME, PACKAGE_NAME } from '../opera/branding.js';
14
14
  import { hideBin, yargs } from '../third_party/index.js';
15
15
  import { checkForUpdates } from '../utils/check-for-updates.js';
16
16
  import { VERSION } from '../version.js';
17
- import { commands } from './chrome-devtools-cli-options.js';
18
- import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
17
+ import { commands } from '../config/cli-options.js';
18
+ import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/mcp-options.js';
19
19
  process.title = CLI_BIN_NAME;
20
20
  await checkForUpdates(`Run \`npm install -g ${PACKAGE_NAME}@latest\` and \`${CLI_BIN_NAME} start\` to update and restart the daemon.`);
21
+ const DEFAULT_CLI_ARGS = ['--viaCli'];
21
22
  async function start(args, sessionId) {
22
- const combinedArgs = [...args, ...defaultArgs];
23
+ const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
23
24
  await startDaemon(combinedArgs, sessionId);
24
25
  logDisclaimers(parseArguments(VERSION, combinedArgs));
25
26
  }
26
- const defaultArgs = ['--viaCli', '--experimentalStructuredContent'];
27
- const startCliOptions = {
28
- ...cliOptions,
29
- };
30
- // Missing CLI serialization.
31
- delete startCliOptions.viewport;
32
- // Change the defaults for the CLI.
33
- delete startCliOptions.experimentalStructuredContent;
34
- delete startCliOptions.experimentalInteropTools;
35
- delete startCliOptions.experimentalPageIdRouting;
36
- if (!('default' in cliOptions.headless)) {
37
- throw new Error('headless cli option unexpectedly does not have a default');
27
+ function getCliOptions() {
28
+ const options = {
29
+ ...getMcpOptionsForViaCli(),
30
+ };
31
+ // Missing CLI serialization.
32
+ delete options.viewport;
33
+ // Change the defaults for the CLI.
34
+ delete options.experimentalStructuredContent;
35
+ delete options.experimentalInteropTools;
36
+ return options;
38
37
  }
39
- if ('default' in cliOptions.isolated) {
40
- throw new Error('isolated cli option unexpectedly has a default');
41
- }
42
- startCliOptions.headless.default = true;
43
- startCliOptions.isolated.description =
44
- 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
45
- startCliOptions.categoryExtensions.default = true;
46
38
  const y = yargs(hideBin(process.argv))
47
39
  .locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways
48
40
  .scriptName(CLI_BIN_NAME)
@@ -74,9 +66,9 @@ const y = yargs(hideBin(process.argv))
74
66
  console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
75
67
  console.error('In the `chrome-devtools` CLI:');
76
68
  console.error('1. Required parameters MUST be passed as positional arguments (without flags).');
77
- console.error(' - INCORRECT: chrome-devtools evaluate_script --expression "() => document.title"');
78
- console.error(' - CORRECT: chrome-devtools evaluate_script "() => document.title"');
79
- console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --pageId 1).');
69
+ console.error(' - INCORRECT: chrome-devtools click --pageId 1 --uid "1_2"');
70
+ console.error(' - CORRECT: chrome-devtools click 1 "1_2"');
71
+ console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
80
72
  console.error('3. Make sure to escape quotes properly for your shell environment.');
81
73
  console.error('Run `chrome-devtools <command> --help` to see exact positional and optional parameters.');
82
74
  console.error('=========================================');
@@ -88,20 +80,27 @@ const y = yargs(hideBin(process.argv))
88
80
  process.exit(1);
89
81
  });
90
82
  y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
91
- .options(startCliOptions)
83
+ .options(getCliOptions())
92
84
  .example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
93
85
  .strict(), async (argv) => {
94
86
  if (isDaemonRunning(argv.sessionId)) {
95
87
  await stopDaemon(argv.sessionId);
96
88
  }
97
89
  // Defaults but we do not want to affect the yargs conflict resolution.
98
- if (argv.isolated === undefined && argv.userDataDir === undefined) {
90
+ if (argv.isolated === undefined &&
91
+ argv.userDataDir === undefined &&
92
+ !argv.autoConnect &&
93
+ !argv.browserUrl &&
94
+ !argv.wsEndpoint) {
99
95
  argv.isolated = true;
100
96
  }
101
- if (argv.headless === undefined) {
97
+ if (argv.headless === undefined &&
98
+ !argv.autoConnect &&
99
+ !argv.browserUrl &&
100
+ !argv.wsEndpoint) {
102
101
  argv.headless = true;
103
102
  }
104
- const args = serializeArgs(cliOptions, argv);
103
+ const args = serializeArgs(mcpOptions, argv);
105
104
  await start(args, argv.sessionId);
106
105
  process.exit(0);
107
106
  }).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
@@ -195,7 +194,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
195
194
  ? verifyDaemonVersion(sessionId, VERSION)
196
195
  : Promise.resolve(undefined);
197
196
  if (!isDaemonRunning(sessionId)) {
198
- await start(serializeArgs(cliOptions, argv), sessionId);
197
+ await start(serializeArgs(mcpOptions, argv), sessionId);
199
198
  }
200
199
  const commandArgs = {};
201
200
  for (const argName of Object.keys(args)) {
@@ -4,5 +4,5 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  // Compatibility shim. The canonical file is generated by `npm run cli:generate`.
7
- export * from './chrome-devtools-cli-options.js';
7
+ export * from '../config/cli-options.js';
8
8
  //# sourceMappingURL=opera-devtools-cli-options.js.map
@@ -4,5 +4,5 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  // Compatibility shim. Prefer importing the canonical path directly.
7
- export * from './chrome-devtools-mcp-cli-options.js';
7
+ export * from '../config/mcp-options.js';
8
8
  //# sourceMappingURL=opera-devtools-mcp-cli-options.js.map
@@ -11,27 +11,16 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { puppeteer } from './third_party/index.js';
13
13
  import { logger, puppeteerLogger } from './utils/logger.js';
14
+ import { isAllowedUrl } from './utils/url.js';
14
15
  let browser;
15
16
  let browserMode;
16
- function makeTargetFilter(enableExtensions = false) {
17
- const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
18
- if (!enableExtensions) {
19
- ignoredPrefixes.add('chrome-extension://');
20
- }
17
+ export function makeTargetFilter(enableExtensions = false) {
21
18
  return function targetFilter(target) {
22
- if (target.url() === 'chrome://newtab/') {
23
- return true;
24
- }
25
- // Could be the only page opened in the browser.
26
- if (target.url().startsWith('chrome://inspect')) {
19
+ const url = target.url();
20
+ if (!url) {
27
21
  return true;
28
22
  }
29
- for (const prefix of ignoredPrefixes) {
30
- if (target.url().startsWith(prefix)) {
31
- return false;
32
- }
33
- }
34
- return true;
23
+ return isAllowedUrl(url, { categoryExtensions: enableExtensions });
35
24
  };
36
25
  }
37
26
  export async function ensureBrowserConnected(options) {