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
@@ -7,42 +7,45 @@
7
7
  * Modified by Opera Software AS.
8
8
  */
9
9
  import process from 'node:process';
10
- import { startDaemon, stopDaemon, sendCommand, handleResponse, verifyDaemonVersion, } from '../daemon/client.js';
10
+ import { startDaemon, stopDaemon, sendCommand } from '../daemon/client.js';
11
11
  import { isDaemonRunning, serializeArgs, assertValidSessionId, } from '../daemon/utils.js';
12
12
  import { logDisclaimers } from '../index.js';
13
13
  import { CLI_BIN_NAME, MCP_BIN_NAME, PACKAGE_NAME } from '../opera/branding.js';
14
+ import { describeBrowserMode } from '../opera/browserFlags.js';
15
+ import { EXIT_CODES } from '../opera/cdpErrors.js';
16
+ import { registerOperaCommands, registerToolCommand, } from '../opera/cliCommands.js';
17
+ import { launcherMigrationNotice } from '../opera/launcherNotice.js';
14
18
  import { hideBin, yargs } from '../third_party/index.js';
15
19
  import { checkForUpdates } from '../utils/check-for-updates.js';
16
20
  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';
21
+ import { commands } from '../config/cli-options.js';
22
+ import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/mcp-options.js';
19
23
  process.title = CLI_BIN_NAME;
20
24
  await checkForUpdates(`Run \`npm install -g ${PACKAGE_NAME}@latest\` and \`${CLI_BIN_NAME} start\` to update and restart the daemon.`);
25
+ const DEFAULT_CLI_ARGS = ['--viaCli'];
26
+ // `--version` is also what a user runs when something looks wrong, so it has to
27
+ // carry the phase-2 recipe when the compatibility launcher is what ran this CLI
28
+ // (`src/opera/launcherNotice.ts`). Without the marker the banner is the plain
29
+ // version, exactly as before.
30
+ const versionBanner = [VERSION, launcherMigrationNotice()]
31
+ .filter(Boolean)
32
+ .join('\n\n');
21
33
  async function start(args, sessionId) {
22
- const combinedArgs = [...args, ...defaultArgs];
34
+ const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
23
35
  await startDaemon(combinedArgs, sessionId);
24
36
  logDisclaimers(parseArguments(VERSION, combinedArgs));
25
37
  }
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');
38
+ function getCliOptions() {
39
+ const options = {
40
+ ...getMcpOptionsForViaCli(),
41
+ };
42
+ // Missing CLI serialization.
43
+ delete options.viewport;
44
+ // Change the defaults for the CLI.
45
+ delete options.experimentalStructuredContent;
46
+ delete options.experimentalInteropTools;
47
+ return options;
38
48
  }
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
49
  const y = yargs(hideBin(process.argv))
47
50
  .locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways
48
51
  .scriptName(CLI_BIN_NAME)
@@ -60,7 +63,7 @@ const y = yargs(hideBin(process.argv))
60
63
  },
61
64
  })
62
65
  .demandCommand()
63
- .version(VERSION)
66
+ .version(versionBanner)
64
67
  .strict()
65
68
  .help(true)
66
69
  .wrap(120)
@@ -72,36 +75,45 @@ const y = yargs(hideBin(process.argv))
72
75
  msg.includes('Unknown arguments')) {
73
76
  console.error('\n=========================================');
74
77
  console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
75
- console.error('In the `chrome-devtools` CLI:');
78
+ console.error(`In the \`${CLI_BIN_NAME}\` CLI:`);
76
79
  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).');
80
+ console.error(` - INCORRECT: ${CLI_BIN_NAME} click --pageId 1 --uid "1_2"`);
81
+ console.error(` - CORRECT: ${CLI_BIN_NAME} click 1 "1_2"`);
82
+ console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
80
83
  console.error('3. Make sure to escape quotes properly for your shell environment.');
81
- console.error('Run `chrome-devtools <command> --help` to see exact positional and optional parameters.');
84
+ console.error(`Run \`${CLI_BIN_NAME} <command> --help\` to see exact positional and optional parameters.`);
82
85
  console.error('=========================================');
83
86
  }
84
87
  }
85
88
  else if (err) {
86
89
  console.error(err);
87
90
  }
88
- process.exit(1);
91
+ // A parse failure is exactly what exit code 2 means — "fix the command" — so
92
+ // it is reported as one rather than as an unknown failure.
93
+ process.exit(msg ? EXIT_CODES.VALIDATION_ERROR : EXIT_CODES.UNKNOWN);
89
94
  });
90
95
  y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
91
- .options(startCliOptions)
96
+ .options(getCliOptions())
92
97
  .example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
93
98
  .strict(), async (argv) => {
94
99
  if (isDaemonRunning(argv.sessionId)) {
95
100
  await stopDaemon(argv.sessionId);
96
101
  }
97
102
  // Defaults but we do not want to affect the yargs conflict resolution.
98
- if (argv.isolated === undefined && argv.userDataDir === undefined) {
103
+ if (argv.isolated === undefined &&
104
+ argv.userDataDir === undefined &&
105
+ !argv.autoConnect &&
106
+ !argv.browserUrl &&
107
+ !argv.wsEndpoint) {
99
108
  argv.isolated = true;
100
109
  }
101
- if (argv.headless === undefined) {
110
+ if (argv.headless === undefined &&
111
+ !argv.autoConnect &&
112
+ !argv.browserUrl &&
113
+ !argv.wsEndpoint) {
102
114
  argv.headless = true;
103
115
  }
104
- const args = serializeArgs(cliOptions, argv);
116
+ const args = serializeArgs(mcpOptions, argv);
105
117
  await start(args, argv.sessionId);
106
118
  process.exit(0);
107
119
  }).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
@@ -114,9 +126,12 @@ y.command('status', `Checks if ${MCP_BIN_NAME} is running`, y => y, async (argv)
114
126
  if (response.success) {
115
127
  const data = JSON.parse(response.result);
116
128
  console.log(`pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`);
129
+ // Who owns the browser decides what recovery to expect from it: we
130
+ // relaunch what we launched, and never touch a browser we attached to.
131
+ console.log(`browser=${describeBrowserMode(data.args)}`);
117
132
  console.log(`args=${JSON.stringify(data.args)}`);
118
133
  if (data.version !== VERSION) {
119
- console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run 'chrome-devtools start' to update and restart the daemon.`);
134
+ console.warn(`Warning: Daemon server version (${data.version}) does not match CLI version (${VERSION}). Run '${CLI_BIN_NAME} start' to update and restart the daemon.`);
120
135
  }
121
136
  }
122
137
  else {
@@ -137,96 +152,13 @@ y.command('stop', `Stop ${MCP_BIN_NAME} if any`, y => y, async (argv) => {
137
152
  await stopDaemon(sessionId);
138
153
  process.exit(0);
139
154
  });
155
+ // The fork's own commands (`setup`, `doctor`, `logs`, `url`) and the wrapper
156
+ // that turns a generated tool definition into a runnable command live in
157
+ // `src/opera/cliCommands.ts`, along with the exit-code and streaming plumbing
158
+ // of a tool call. `start` stays here: it is upstream's, and prepends `--viaCli`.
159
+ registerOperaCommands(y, { start });
140
160
  for (const [commandName, commandDef] of Object.entries(commands)) {
141
- const args = commandDef.args;
142
- const requiredArgNames = Object.keys(args).filter(name => args[name].required);
143
- const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
144
- let commandStr = commandName;
145
- for (const arg of requiredArgNames) {
146
- commandStr += ` <${arg}>`;
147
- }
148
- for (const arg of optionalArgNames) {
149
- commandStr += ` [--${arg}]`;
150
- }
151
- y.command(commandStr, commandDef.description, y => {
152
- y.option('output-format', {
153
- choices: ['md', 'json'],
154
- default: 'md',
155
- });
156
- for (const [argName, opt] of Object.entries(args)) {
157
- const type = opt.type === 'integer' || opt.type === 'number'
158
- ? 'number'
159
- : opt.type === 'boolean'
160
- ? 'boolean'
161
- : opt.type === 'array'
162
- ? 'array'
163
- : 'string';
164
- if (opt.required) {
165
- const options = {
166
- describe: opt.description,
167
- type: type,
168
- };
169
- if (opt.default !== undefined) {
170
- options.default = opt.default;
171
- }
172
- if (opt.enum) {
173
- options.choices = opt.enum;
174
- }
175
- y.positional(argName, options);
176
- }
177
- else {
178
- const options = {
179
- describe: opt.description,
180
- type: type,
181
- };
182
- if (opt.default !== undefined) {
183
- options.default = opt.default;
184
- }
185
- if (opt.enum) {
186
- options.choices = opt.enum;
187
- }
188
- y.option(argName, options);
189
- }
190
- }
191
- }, async (argv) => {
192
- const sessionId = argv.sessionId;
193
- try {
194
- const versionWarningPromise = isDaemonRunning(sessionId)
195
- ? verifyDaemonVersion(sessionId, VERSION)
196
- : Promise.resolve(undefined);
197
- if (!isDaemonRunning(sessionId)) {
198
- await start(serializeArgs(cliOptions, argv), sessionId);
199
- }
200
- const commandArgs = {};
201
- for (const argName of Object.keys(args)) {
202
- if (argName in argv) {
203
- commandArgs[argName] = argv[argName];
204
- }
205
- }
206
- const response = await sendCommand({
207
- method: 'invoke_tool',
208
- tool: commandName,
209
- args: commandArgs,
210
- }, sessionId);
211
- if (response.success) {
212
- console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
213
- }
214
- else {
215
- console.error('Error:', response.error);
216
- }
217
- const versionWarning = await versionWarningPromise;
218
- if (versionWarning) {
219
- console.warn(versionWarning);
220
- }
221
- if (!response.success) {
222
- process.exit(1);
223
- }
224
- }
225
- catch (error) {
226
- console.error('Failed to execute command:', error);
227
- process.exit(1);
228
- }
229
- });
161
+ registerToolCommand(y, commandName, commandDef, { start });
230
162
  }
231
163
  await y.parse();
232
164
  //# sourceMappingURL=chrome-devtools.js.map
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Opera Software AS.
5
+ * SPDX-License-Identifier: Apache-2.0
6
+ */
7
+ // Opera-named bin entry (see `package.json` `bin`). The implementation lives in
8
+ // the upstream-owned file and is branded via `src/opera/branding.ts`.
9
+ //
10
+ // The guard comes FIRST, and has to stay first: it installs on import, and the
11
+ // imports below it reach `third_party`, whose bundled `debug` reads the
12
+ // `localStorage` global and prints Node's Web Storage warning. An import listed
13
+ // before this one that reaches `third_party` puts the warning back in front of
14
+ // every command's output.
15
+ import { preloadWebStorageWarningGuardInChildren } from '../opera/webStorageWarning.js';
16
+ import { CLI_BIN_NAME } from '../opera/branding.js';
17
+ import { extractTakeoverFlag, preflightBrowser, sessionIdFromArgv, } from '../opera/browserTarget.js';
18
+ import { formatError } from '../opera/cliOutput.js';
19
+ import { autoConfigure, shouldAutoConfigure } from '../opera/config.js';
20
+ import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
21
+ import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
22
+ /**
23
+ * Configure a fresh machine in place, without asking. Skips pure queries and
24
+ * the (future) `setup`/`logs` commands; reports what it did on stderr so a
25
+ * scripted user sees the same first-run note as opera-browser-cli.
26
+ */
27
+ function ensureConfigured(argv) {
28
+ if (!shouldAutoConfigure(argv)) {
29
+ return;
30
+ }
31
+ const command = argv[2];
32
+ const result = autoConfigure();
33
+ if (result.status === 'configured') {
34
+ const ai = result.browser.isNeon
35
+ ? 'Opera AI available'
36
+ : 'chat only — install Opera Neon for invoke-do/make/research';
37
+ process.stderr.write(`configured: ${result.browser.name} (${ai}) — run \`${CLI_BIN_NAME} setup\` to change\n`);
38
+ return;
39
+ }
40
+ if (result.status === 'no-browser' && command !== 'doctor') {
41
+ process.stderr.write(`hint: no Opera installation found — run \`${CLI_BIN_NAME} setup\`, or set OPERA_CLI_EXECUTABLE_PATH\n`);
42
+ }
43
+ }
44
+ // Promote ~/.opera-browser-cli/config into process.env (and warn on unknown
45
+ // keys) before the CLI spawns the daemon, which inherits this environment.
46
+ loadOperaCliConfig();
47
+ // A machine that upgraded from the two-package era may still have the old HTTP
48
+ // bridge running, holding port 9225 and a browser of its own. Nothing runs at
49
+ // install time any more — npm ≥12 blocks install scripts unless the user opts in
50
+ // — so this is the path that always runs: the launcher spawns this CLI for every
51
+ // command, `--version` and `--help` included. One file read when there is no
52
+ // bridge; the port probe behind it runs once per boot. Never throws.
53
+ //
54
+ // The home is the *invoking* user's, not `os.homedir()`: under `sudo`, the
55
+ // bridge (and its PID file) belongs to the user who started it.
56
+ await runLegacyMigrationGuard();
57
+ // The daemon cannot import this module before its own static imports reach
58
+ // `third_party`, so the guard travels to it (and to the MCP server it spawns)
59
+ // through NODE_OPTIONS instead.
60
+ preloadWebStorageWarningGuardInChildren();
61
+ // On a machine that has never been configured, detect the installed Opera
62
+ // build and write a config + set OPERA_CLI_* so this first command works.
63
+ ensureConfigured(process.argv);
64
+ // `--takeover` decides how a profile conflict is settled, and belongs to the
65
+ // preflight rather than to any command — every command parser is strict, so it
66
+ // is read and removed before one of them sees it. It is left in the environment
67
+ // because the preflight is not the only place a conflict is settled: a tool
68
+ // call that fails on one settles it too, one process deeper than any argument
69
+ // the command parser kept (`cliCommands.ts`).
70
+ const takeover = extractTakeoverFlag(process.argv);
71
+ if (takeover) {
72
+ process.env.OPERA_CLI_TAKEOVER = '1';
73
+ }
74
+ // Which browser to drive is decided here, before the command starts a daemon:
75
+ // settling a conflict may need to ask the user, and the daemon is detached with
76
+ // no terminal. The decision travels as OPERA_CLI_* environment, which the
77
+ // daemon and the MCP server it spawns both inherit — and, for `start`, as the
78
+ // flags translated from it immediately below.
79
+ try {
80
+ await preflightBrowser(process.argv.slice(2), sessionIdFromArgv(process.argv), takeover);
81
+ }
82
+ catch (error) {
83
+ const { output, exitCode } = await formatError(error);
84
+ console.error(output);
85
+ process.exit(exitCode);
86
+ }
87
+ // `start` is the command that decides the daemon's browser options, and its own
88
+ // defaults (`--headless` from the viaCli options, `--isolated`) are serialized
89
+ // as if the user had passed them — so the MCP server bin's `applyEnvToArgv`,
90
+ // which skips a flag already on argv, can no longer see OPERA_CLI_HEADED or
91
+ // OPERA_CLI_USER_DATA_DIR. Translating the environment into explicit flags
92
+ // here, before yargs parses the command, lets the config beat those defaults.
93
+ // Only `start` takes the browser flags; `status`, `stop` and the tool commands
94
+ // have strict parsers that would reject them, and a directly-run MCP server
95
+ // still does the translation in its own bin.
96
+ if (process.argv[2] === 'start') {
97
+ applyEnvToArgv(process.argv);
98
+ }
99
+ // Dynamic import: static imports are hoisted above the module body, so the
100
+ // config load above would otherwise run after the CLI already parsed argv.
101
+ await import('./chrome-devtools.js');
102
+ //# sourceMappingURL=opera-browser-cli.js.map
@@ -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
@@ -6,5 +6,24 @@
6
6
  */
7
7
  // Opera-named bin entry (see `package.json` `bin`). The implementation lives in
8
8
  // the upstream-owned file and is branded via `src/opera/branding.ts`.
9
- import './chrome-devtools-mcp.js';
9
+ import { applyEnvToArgv, loadOperaCliConfig } from '../opera/envConfig.js';
10
+ import { runLegacyMigrationGuard } from '../opera/legacyBridgeCleanup.js';
11
+ // Import for its side effect: silences Node's Web Storage warning before
12
+ // `third_party` reads `localStorage`. See `opera/webStorageWarning.ts`.
13
+ import '../opera/webStorageWarning.js';
14
+ // Apply ~/.opera-browser-cli/config and OPERA_CLI_* env vars before the
15
+ // upstream implementation reads `process.argv`.
16
+ loadOperaCliConfig();
17
+ applyEnvToArgv(process.argv);
18
+ // An MCP client starts this bin directly, with no CLI invocation anywhere, so
19
+ // the migration guard has to run here too: a machine that upgraded from the
20
+ // two-package era may still have the old HTTP bridge (port 9225) holding a
21
+ // browser. Nothing runs at install time — npm ≥12 blocks install scripts unless
22
+ // the user opts in — and this file is also the server the daemon spawns, so both
23
+ // shapes of MCP start are covered. One file read when there is no bridge; the
24
+ // port probe behind it runs once per boot. Never throws, never writes to stdout.
25
+ await runLegacyMigrationGuard();
26
+ // Dynamic import: static imports are hoisted above the module body, so the
27
+ // env/config setup above would otherwise run after the server parsed argv.
28
+ await import('./chrome-devtools-mcp.js');
10
29
  //# sourceMappingURL=opera-devtools-mcp.js.map
@@ -9,29 +9,20 @@ import { execSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
+ import { disarmBrowserOrphanCleanup, watchBrowserForOrphans, } from './opera/browserCleanup.js';
13
+ import { attachFailed, noDevToolsEndpoint, profileInUse, } from './opera/browserErrors.js';
12
14
  import { puppeteer } from './third_party/index.js';
13
15
  import { logger, puppeteerLogger } from './utils/logger.js';
16
+ import { isAllowedUrl } from './utils/url.js';
14
17
  let browser;
15
18
  let browserMode;
16
- function makeTargetFilter(enableExtensions = false) {
17
- const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
18
- if (!enableExtensions) {
19
- ignoredPrefixes.add('chrome-extension://');
20
- }
19
+ export function makeTargetFilter(enableExtensions = false) {
21
20
  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')) {
21
+ const url = target.url();
22
+ if (!url) {
27
23
  return true;
28
24
  }
29
- for (const prefix of ignoredPrefixes) {
30
- if (target.url().startsWith(prefix)) {
31
- return false;
32
- }
33
- }
34
- return true;
25
+ return isAllowedUrl(url, { categoryExtensions: enableExtensions });
35
26
  };
36
27
  }
37
28
  export async function ensureBrowserConnected(options) {
@@ -84,9 +75,7 @@ export async function ensureBrowserConnected(options) {
84
75
  connectOptions.browserWSEndpoint = browserWSEndpoint;
85
76
  }
86
77
  catch (error) {
87
- throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, {
88
- cause: error,
89
- });
78
+ throw new Error(noDevToolsEndpoint(userDataDir), { cause: error });
90
79
  }
91
80
  }
92
81
  else {
@@ -109,9 +98,7 @@ export async function ensureBrowserConnected(options) {
109
98
  browser = connected;
110
99
  }
111
100
  catch (err) {
112
- throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
113
- cause: err,
114
- });
101
+ throw new Error(attachFailed(options, autoConnect), { cause: err });
115
102
  }
116
103
  logger?.('Connected Puppeteer');
117
104
  return browser;
@@ -201,9 +188,10 @@ export async function launch(options) {
201
188
  catch (error) {
202
189
  if (userDataDir &&
203
190
  error.message.includes('The browser is already running')) {
204
- throw new Error(`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, {
205
- cause: error,
206
- });
191
+ // The wording lives in `opera/browserErrors.ts`: upstream's answer to a
192
+ // profile in use ("use --isolated") is the wrong one for the case that
193
+ // matters — driving the browser you already have.
194
+ throw new Error(profileInUse(userDataDir), { cause: error });
207
195
  }
208
196
  throw error;
209
197
  }
@@ -214,6 +202,12 @@ export async function ensureBrowserLaunched(options) {
214
202
  }
215
203
  // Assign mode before browser; see the connect path above for rationale.
216
204
  const launched = await launch(options);
205
+ // Chrome's helpers sit in the browser's process group, not under it in the
206
+ // process tree, so a browser that dies without closing — SIGKILL, a crash,
207
+ // an OOM kill — leaves them running as orphans. `disconnected` is the only
208
+ // signal that the browser is gone, and the group kill it arms is the only
209
+ // teardown that reaches the helpers; see `opera/browserCleanup.ts`.
210
+ watchBrowserForOrphans(launched);
217
211
  browserMode = 'launched';
218
212
  browser = launched;
219
213
  return browser;
@@ -237,6 +231,9 @@ export async function closeBrowser() {
237
231
  return;
238
232
  }
239
233
  if (mode === 'launched') {
234
+ // The close below emits `disconnected` too; without this the orphan-cleanup
235
+ // handler would SIGKILL the group mid-shutdown. See `opera/browserCleanup.ts`.
236
+ disarmBrowserOrphanCleanup();
240
237
  await b.close().catch(err => {
241
238
  logger?.('Failed to close browser', err);
242
239
  });
@@ -248,6 +245,7 @@ export async function closeBrowser() {
248
245
  }
249
246
  export async function closeBrowserIfOpen() {
250
247
  if (browser?.connected) {
248
+ disarmBrowserOrphanCleanup();
251
249
  try {
252
250
  await browser.close();
253
251
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ *
6
+ * Modified by Opera Software AS.
7
+ */
8
+ import { CACHE_DIR_NAME, MCP_BIN_NAME, REPO_URL } from '../opera/branding.js';
9
+ export const browserOptions = {
10
+ autoConnect: {
11
+ type: 'boolean',
12
+ description: 'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remote debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.',
13
+ conflicts: ['isolated', 'executablePath'],
14
+ default: false,
15
+ coerce: (value) => {
16
+ if (!value) {
17
+ return;
18
+ }
19
+ return value;
20
+ },
21
+ },
22
+ browserUrl: {
23
+ type: 'string',
24
+ description: `Connect to a running, debuggable Chrome instance (e.g. \`http://127.0.0.1:9222\`). For more details see: ${REPO_URL}#connecting-to-a-running-chrome-instance.`,
25
+ alias: 'u',
26
+ conflicts: ['wsEndpoint'],
27
+ coerce: (url) => {
28
+ if (!url) {
29
+ return;
30
+ }
31
+ try {
32
+ new URL(url);
33
+ }
34
+ catch {
35
+ throw new Error(`Provided browserUrl ${url} is not valid URL.`);
36
+ }
37
+ return url;
38
+ },
39
+ },
40
+ wsEndpoint: {
41
+ type: 'string',
42
+ description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.',
43
+ alias: 'w',
44
+ conflicts: ['browserUrl'],
45
+ coerce: (url) => {
46
+ if (!url) {
47
+ return;
48
+ }
49
+ try {
50
+ const parsed = new URL(url);
51
+ if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
52
+ throw new Error(`Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`);
53
+ }
54
+ return url;
55
+ }
56
+ catch (error) {
57
+ if (error.message.includes('ws://')) {
58
+ throw error;
59
+ }
60
+ throw new Error(`Provided wsEndpoint ${url} is not valid URL.`);
61
+ }
62
+ },
63
+ },
64
+ wsHeaders: {
65
+ type: 'string',
66
+ description: 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --wsEndpoint.',
67
+ implies: 'wsEndpoint',
68
+ coerce: (val) => {
69
+ if (!val) {
70
+ return;
71
+ }
72
+ try {
73
+ const parsed = JSON.parse(val);
74
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
75
+ throw new Error('Headers must be a JSON object');
76
+ }
77
+ return parsed;
78
+ }
79
+ catch (error) {
80
+ throw new Error(`Invalid JSON for wsHeaders: ${error.message}`);
81
+ }
82
+ },
83
+ },
84
+ headless: {
85
+ type: 'boolean',
86
+ description: 'Whether to run in headless (no UI) mode.',
87
+ default: false,
88
+ },
89
+ executablePath: {
90
+ type: 'string',
91
+ description: 'Path to custom Chrome executable.',
92
+ conflicts: ['browserUrl', 'wsEndpoint'],
93
+ alias: 'e',
94
+ },
95
+ isolated: {
96
+ type: 'boolean',
97
+ description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.',
98
+ },
99
+ userDataDir: {
100
+ type: 'string',
101
+ description: `Path to the user data directory for Chrome. Default is $HOME/.cache/${CACHE_DIR_NAME}/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE`,
102
+ conflicts: ['browserUrl', 'wsEndpoint', 'isolated'],
103
+ },
104
+ channel: {
105
+ type: 'string',
106
+ description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.',
107
+ choices: ['canary', 'dev', 'beta', 'stable'],
108
+ conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'],
109
+ },
110
+ proxyServer: {
111
+ type: 'string',
112
+ description: `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.`,
113
+ },
114
+ chromeArg: {
115
+ type: 'array',
116
+ describe: `Additional arguments for Chrome. Only applies when Chrome is launched by ${MCP_BIN_NAME}.`,
117
+ },
118
+ ignoreDefaultChromeArg: {
119
+ type: 'array',
120
+ describe: `Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by ${MCP_BIN_NAME}.`,
121
+ },
122
+ };
123
+ export function getBrowserOptions() {
124
+ return browserOptions;
125
+ }
126
+ //# sourceMappingURL=browser-options.js.map