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
@@ -4,11 +4,17 @@
4
4
  * Copyright 2026 Google LLC
5
5
  * SPDX-License-Identifier: Apache-2.0
6
6
  */
7
- import fs, { constants, openSync, writeSync, closeSync } from 'node:fs';
7
+ import fs, { constants, writeSync, closeSync } from 'node:fs';
8
8
  import { createServer } from 'node:net';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import process from 'node:process';
12
+ import { claimPidFile } from '../opera/daemonPidFile.js';
13
+ import { installShutdownHandlers, recordShutdownReason, } from '../opera/daemonShutdown.js';
14
+ import { dispatchSocketMessage, reportStartupFailure, } from '../opera/daemonSocket.js';
15
+ import { attachLogForwarding } from '../opera/daemonStreaming.js';
16
+ import { callDaemonTool } from '../opera/daemonToolCall.js';
17
+ import { superviseMcpServer } from '../opera/mcpServerSupervisor.js';
12
18
  import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
13
19
  import { logger, puppeteerLogger } from '../utils/logger.js';
14
20
  import { VERSION } from '../version.js';
@@ -54,16 +60,11 @@ catch (err) {
54
60
  }
55
61
  let fd = -1;
56
62
  try {
57
- // Open the file with flags to:
58
- // - O_WRONLY: Write-only
59
- // - O_CREAT: Create if it doesn't exist
60
- // - O_TRUNC: Truncate to zero length if it exists
61
- // - O_NOFOLLOW: DO NOT follow symlinks.
62
- // - 0o600: Permissions: read/write for owner, no permissions for others.
63
- fd = openSync(pidFilePath, constants.O_WRONLY |
64
- constants.O_CREAT |
65
- constants.O_TRUNC |
66
- constants.O_NOFOLLOW, 0o600);
63
+ // The claim is `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` — write only,
64
+ // create if absent, fail if another daemon owns it, never follow a symlink —
65
+ // at 0o600. See `opera/daemonPidFile.ts` for why it is not upstream's
66
+ // `O_TRUNC`.
67
+ fd = claimPidFile(sessionId);
67
68
  writeSync(fd, process.pid.toString());
68
69
  }
69
70
  catch (err) {
@@ -88,33 +89,54 @@ const mcpServerArgs = process.argv.slice(2);
88
89
  let mcpClient = null;
89
90
  let mcpTransport = null;
90
91
  let server = null;
92
+ /** Set once `listen` succeeds, so a later failure knows it owns the socket. */
93
+ let bound = false;
94
+ /** Brings the MCP server back when its stdio child dies; see the module. */
95
+ const mcpSupervisor = superviseMcpServer({
96
+ sessionId,
97
+ connect: setupMCPClient,
98
+ handles: () => ({ client: mcpClient, transport: mcpTransport }),
99
+ giveUp: () => cleanup(1),
100
+ });
91
101
  async function setupMCPClient() {
92
102
  console.log('Setting up MCP client connection...');
93
103
  // Create stdio transport for chrome-devtools-mcp
94
- mcpTransport = new StdioClientTransport({
104
+ const transport = new StdioClientTransport({
95
105
  command: process.execPath,
96
106
  args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
97
107
  env: process.env,
98
108
  });
109
+ mcpTransport = transport;
99
110
  mcpClient = new Client({
100
111
  name: DAEMON_CLIENT_NAME,
101
112
  version: VERSION,
102
113
  }, {
103
114
  capabilities: {},
104
115
  });
105
- await mcpClient.connect(mcpTransport);
116
+ // Set onclose BEFORE connect: the SDK's Protocol.connect() captures the
117
+ // transport's existing onclose and wraps it. Setting it after connect
118
+ // overwrites that wrapper, so _onclose() never runs and pending callTool
119
+ // requests hang forever when the MCP server dies. The callback carries the
120
+ // transport it belongs to, so a respawn can tell a server it replaced from the
121
+ // one it is watching.
122
+ transport.onclose = () => mcpSupervisor.onTransportClosed(transport);
123
+ await mcpClient.connect(transport);
124
+ // Opera AI tools stream their output as `notifications/message` chunks; the
125
+ // socket protocol carries them to the CLI. See `opera/daemonStreaming.ts`.
126
+ attachLogForwarding(mcpClient);
106
127
  console.log('MCP client connected');
107
128
  }
108
- async function handleRequest(msg) {
129
+ async function handleRequest(msg, streamToken) {
109
130
  try {
110
131
  if (msg.method === 'invoke_tool') {
111
132
  if (!mcpClient) {
112
133
  throw new Error('MCP client not initialized');
113
134
  }
114
135
  const { tool, args } = msg;
115
- const result = (await mcpClient.callTool({
116
- name: tool,
117
- arguments: args || {},
136
+ const result = (await callDaemonTool(mcpClient, {
137
+ tool,
138
+ args,
139
+ streamToken,
118
140
  }));
119
141
  return {
120
142
  success: true,
@@ -126,7 +148,7 @@ async function handleRequest(msg) {
126
148
  await started;
127
149
  // Trigger cleanup asynchronously.
128
150
  setImmediate(() => {
129
- void cleanup();
151
+ void cleanup(0, 'stopped by request');
130
152
  });
131
153
  return {
132
154
  success: true,
@@ -177,7 +199,7 @@ async function startSocketServer() {
177
199
  const transport = new PipeTransport(socket, socket, puppeteerLogger);
178
200
  transport.onmessage = async (message) => {
179
201
  logger?.('onmessage', message);
180
- const response = await handleRequest(JSON.parse(message));
202
+ const response = await dispatchSocketMessage(message, transport, handleRequest);
181
203
  transport.send(JSON.stringify(response));
182
204
  socket.end();
183
205
  };
@@ -190,6 +212,7 @@ async function startSocketServer() {
190
212
  readableAll: false,
191
213
  writableAll: false,
192
214
  }, async () => {
215
+ bound = true;
193
216
  console.log(`Daemon server listening on ${socketPath}`);
194
217
  try {
195
218
  // Setup MCP client
@@ -206,7 +229,14 @@ async function startSocketServer() {
206
229
  });
207
230
  });
208
231
  }
209
- async function cleanup(exitCode = 0) {
232
+ async function cleanup(exitCode = 0, reason) {
233
+ // The reason is the diagnostic, so it goes first: `recordShutdownReason` is
234
+ // best-effort, and nothing else in this teardown should be able to cost us it.
235
+ recordShutdownReason(sessionId, reason);
236
+ // Then stop supervising. Closing the client below closes the transport, which
237
+ // would otherwise land in the respawn path and spawn a replacement server
238
+ // while this teardown is running.
239
+ mcpSupervisor.stop();
210
240
  console.log('Cleaning up daemon...');
211
241
  try {
212
242
  await mcpClient?.close();
@@ -239,28 +269,21 @@ async function cleanup(exitCode = 0) {
239
269
  }
240
270
  process.exit(exitCode);
241
271
  }
242
- // Handle shutdown signals
243
- process.on('SIGTERM', () => {
244
- void cleanup();
245
- });
246
- process.on('SIGINT', () => {
247
- void cleanup();
248
- });
249
- process.on('SIGHUP', () => {
250
- void cleanup();
251
- });
252
- // Handle uncaught errors
253
- process.on('uncaughtException', error => {
254
- logger?.('Uncaught exception:', error);
255
- void cleanup(1);
256
- });
257
- process.on('unhandledRejection', error => {
258
- logger?.('Unhandled rejection:', error);
259
- void cleanup(1);
272
+ // The shutdown wiring — three signals and the two uncaught-error handlers, each
273
+ // naming itself in the reason the CLI reports — lives in
274
+ // `opera/daemonShutdown.ts`. Every path that reaches it records why it left, so
275
+ // "left no reason behind" means only what it says: a SIGKILL or the OOM killer.
276
+ installShutdownHandlers({
277
+ onSignal: reason => cleanup(0, reason),
278
+ onException: reason => cleanup(1, reason),
260
279
  });
261
280
  // Start the server
262
281
  const started = startSocketServer().catch(error => {
263
282
  logger?.('Failed to start daemon server:', error);
264
- void cleanup(1);
283
+ void reportStartupFailure(error, {
284
+ socketBound: bound,
285
+ sessionId,
286
+ teardown: reason => cleanup(1, reason),
287
+ });
265
288
  });
266
289
  //# sourceMappingURL=daemon.js.map
@@ -110,6 +110,12 @@ export function serializeArgs(options, argv) {
110
110
  continue;
111
111
  }
112
112
  const value = argv[key];
113
+ const option = options[key];
114
+ // Yargs reuses the option `default` object; skip it so the daemon parser
115
+ // still sees the original default (needed for filesystemRoot identity).
116
+ if (option !== undefined && value === option.default) {
117
+ continue;
118
+ }
113
119
  const kebabKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
114
120
  if (typeof value === 'boolean') {
115
121
  if (value) {
@@ -19,6 +19,11 @@ export function overrideDevToolsGlobals({ loadResource, }) {
19
19
  DevTools.Host.InspectorFrontendHost.installInspectorFrontendHost(new McpHostBindingAdapter(loadResource));
20
20
  // DevTools CDP errors can get noisy.
21
21
  DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
22
+ const noopAgentCommand = () => {
23
+ return Promise.resolve({
24
+ getError: () => undefined,
25
+ });
26
+ };
22
27
  // Stub out Network emulation commands on the DevTools Agent prototype globally.
23
28
  // This prevents the DevTools Frontend from ever resetting/clearing Puppeteer's
24
29
  // active network blocking/throttling rules during target setup or session lifetime.
@@ -36,41 +41,37 @@ export function overrideDevToolsGlobals({ loadResource, }) {
36
41
  enumerable: true,
37
42
  });
38
43
  Object.defineProperty(networkAgentPrototype, 'invoke_overrideNetworkState', {
39
- value: () => {
40
- return Promise.resolve({
41
- getError: () => undefined,
42
- });
43
- },
44
+ value: noopAgentCommand,
44
45
  writable: true,
45
46
  configurable: true,
46
47
  enumerable: true,
47
48
  });
48
49
  Object.defineProperty(networkAgentPrototype, 'invoke_enable', {
49
- value: () => {
50
- return Promise.resolve({
51
- getError: () => undefined,
52
- });
53
- },
50
+ value: noopAgentCommand,
54
51
  writable: true,
55
52
  configurable: true,
56
53
  enumerable: true,
57
54
  });
58
55
  Object.defineProperty(networkAgentPrototype, 'invoke_disable', {
59
- value: () => {
60
- return Promise.resolve({
61
- getError: () => undefined,
62
- });
63
- },
56
+ value: noopAgentCommand,
64
57
  writable: true,
65
58
  configurable: true,
66
59
  enumerable: true,
67
60
  });
68
61
  Object.defineProperty(networkAgentPrototype, 'invoke_setBlockedURLs', {
69
- value: () => {
70
- return Promise.resolve({
71
- getError: () => undefined,
72
- });
73
- },
62
+ value: noopAgentCommand,
63
+ writable: true,
64
+ configurable: true,
65
+ enumerable: true,
66
+ });
67
+ }
68
+ // Puppeteer already collects issues from its own Audits subscription. Avoid
69
+ // enabling the DevTools Frontend's redundant subscription, which can replay
70
+ // a large retained issue backlog and delay unrelated page work.
71
+ const auditsAgentPrototype = DevTools.ProtocolClient.InspectorBackend.inspectorBackend.agentPrototypes.get('Audits');
72
+ if (auditsAgentPrototype) {
73
+ Object.defineProperty(auditsAgentPrototype, 'invoke_enable', {
74
+ value: noopAgentCommand,
74
75
  writable: true,
75
76
  configurable: true,
76
77
  enumerable: true,
@@ -91,7 +92,7 @@ export function overrideDevToolsGlobals({ loadResource, }) {
91
92
  .resolve('../third_party/devtools-formatter-worker.js'),
92
93
  });
93
94
  }
94
- export async function createTargetUniverse(session) {
95
+ export async function createTargetUniverse(session, options) {
95
96
  const settingStorage = new DevTools.Common.Settings.SettingsStorage({});
96
97
  const universe = new DevTools.Foundation.Universe.Universe({
97
98
  settingsCreationOptions: {
@@ -105,6 +106,11 @@ export async function createTargetUniverse(session) {
105
106
  inspectorFrontendHost: DevTools.Host.InspectorFrontendHost.InspectorFrontendHostInstance,
106
107
  supportsEmulation: false,
107
108
  });
109
+ const sourceMaps = options?.sourceMaps ?? true;
110
+ const jsSourceMapsSetting = universe.settings.resolve(DevTools.SDKSettings.jsSourceMapsEnabledSettingDescriptor);
111
+ jsSourceMapsSetting.set(sourceMaps);
112
+ const cssSourceMapsSetting = universe.settings.resolve(DevTools.SDKSettings.cssSourceMapsEnabledSettingDescriptor);
113
+ cssSourceMapsSetting.set(sourceMaps);
108
114
  const setting = universe.settings.resolve(DevTools.SourceMapManager.lazyLoadingSettingDescriptor);
109
115
  setting.set(true);
110
116
  const skipAllPausesSetting = universe.settings.resolve(DevTools.skipAllPausesSettingDescriptor);
@@ -6,6 +6,7 @@
6
6
  import { isUtf8 } from 'node:buffer';
7
7
  import { DevTools, } from '../third_party/index.js';
8
8
  const BODY_CONTEXT_SIZE_LIMIT = 10000;
9
+ const URL_CONTEXT_SIZE_LIMIT = 255;
9
10
  export class NetworkFormatter {
10
11
  #request;
11
12
  #options;
@@ -178,8 +179,10 @@ function getSizeLimitedString(text, sizeLimit) {
178
179
  return text;
179
180
  }
180
181
  function convertNetworkRequestConciseToString(data) {
181
- // TODO truncate the URL
182
- return `reqid=${data.requestId} ${data.method} ${data.url} [${data.status}]${data.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`;
182
+ // Long URLs (e.g., data: URLs) bloat the concise list output. The full URL
183
+ // remains available via the detailed view and the structured content.
184
+ const url = getSizeLimitedString(data.url, URL_CONTEXT_SIZE_LIMIT);
185
+ return `reqid=${data.requestId} ${data.method} ${url} [${data.status}]${data.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`;
183
186
  }
184
187
  function formatHeaders(headers) {
185
188
  const response = [];
@@ -5,21 +5,22 @@
5
5
  *
6
6
  * Modified by Opera Software AS.
7
7
  */
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
8
10
  import { ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
9
11
  import { loadIssueDescriptions } from './devtools/issueDescriptions.js';
10
12
  import { McpContext } from './McpContext.js';
11
13
  import { BROWSER_EXPOSURE_DISCLAIMER, showUsageStatisticsDisclaimer, } from './opera/policy.js';
12
- import { createOperaToolHooks } from './opera/toolHandlerHooks.js';
13
14
  import { buildLaunchOptions } from './opera/browserLaunch.js';
15
+ import { createOperaToolHooks, } from './opera/toolHandlerHooks.js';
14
16
  import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
15
17
  import { FilePersistence } from './telemetry/persistence.js';
16
- import { McpServer, SetLevelRequestSchema, ListRootsResultSchema, RootsListChangedNotificationSchema, } from './third_party/index.js';
18
+ import { McpServer as SdkMcpServer, SetLevelRequestSchema, ListRootsResultSchema, RootsListChangedNotificationSchema, Mutex, puppeteer, } from './third_party/index.js';
17
19
  import { ToolHandler } from './ToolHandler.js';
18
20
  import { createTools } from './tools/tools.js';
19
21
  import { logger } from './utils/logger.js';
20
- import { Mutex } from './third_party/index.js';
21
22
  import { VERSION } from './version.js';
22
- export { buildFlag } from './ToolHandler.js';
23
+ puppeteer.setFollowSymlinks(false);
23
24
  /**
24
25
  * Timeout for a `roots/list` that a tool call is waiting on, matching the 5s
25
26
  * default used for page operations. `getContext()` awaits it while
@@ -29,84 +30,151 @@ export { buildFlag } from './ToolHandler.js';
29
30
  * slow client sends late still land.
30
31
  */
31
32
  const ROOTS_REQUEST_TIMEOUT = 5_000;
32
- export async function createMcpServer(serverArgs, options) {
33
- // Opera forces `usageStatistics` off in ./opera/policy.ts, so this stays
34
- // dormant. Kept identical to upstream so the seam is the policy, not this file.
35
- if (serverArgs.usageStatistics) {
36
- ClearcutLogger.initialize({
37
- persistence: new FilePersistence(),
38
- logFile: serverArgs.logFile,
39
- appVersion: VERSION,
40
- clearcutEndpoint: serverArgs.clearcutEndpoint,
41
- clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs,
42
- clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader,
33
+ export class McpServer {
34
+ server;
35
+ #serverArgs;
36
+ #options;
37
+ #context;
38
+ /**
39
+ * Client roots stay valid across browser reconnects and only the client can
40
+ * invalidate them through a `roots/list_changed` notification. CLI-configured
41
+ * roots are read from `#serverArgs` when combining roots.
42
+ */
43
+ #lastClientRoots;
44
+ #toolMutex = new Mutex();
45
+ #operaHooks;
46
+ constructor(serverArgs, options = {}) {
47
+ this.#serverArgs = serverArgs;
48
+ this.#options = options;
49
+ this.#operaHooks = createOperaToolHooks({
50
+ serverArgs: this.#serverArgs,
51
+ logFile: this.#options.logFile,
52
+ resetContext: () => {
53
+ this.#context?.dispose();
54
+ this.#context = undefined;
55
+ },
43
56
  });
57
+ if (this.#serverArgs.usageStatistics) {
58
+ ClearcutLogger.initialize({
59
+ persistence: new FilePersistence(),
60
+ logFile: this.#serverArgs.logFile,
61
+ appVersion: VERSION,
62
+ clearcutEndpoint: this.#serverArgs.clearcutEndpoint,
63
+ clearcutForceFlushIntervalMs: this.#serverArgs.clearcutForceFlushIntervalMs,
64
+ clearcutIncludePidHeader: this.#serverArgs.clearcutIncludePidHeader,
65
+ });
66
+ }
67
+ this.server = new SdkMcpServer({
68
+ name: 'chrome_devtools',
69
+ title: 'Chrome DevTools MCP server',
70
+ version: VERSION,
71
+ }, { capabilities: { logging: {} } });
72
+ this.server.server.setRequestHandler(SetLevelRequestSchema, () => {
73
+ return {};
74
+ });
75
+ this.server.server.oninitialized = () => {
76
+ const clientName = this.server.server.getClientVersion()?.name;
77
+ if (clientName) {
78
+ ClearcutLogger.get()?.setClientName(clientName);
79
+ }
80
+ if (this.server.server.getClientCapabilities()?.roots) {
81
+ void this.#updateRoots();
82
+ this.server.server.setNotificationHandler(RootsListChangedNotificationSchema, () => {
83
+ void this.#updateRoots();
84
+ });
85
+ }
86
+ else if (!this.#serverArgs.allowUnrestrictedPaths &&
87
+ (this.#serverArgs.filesystemRoot ?? []).length === 0) {
88
+ console.warn('[chrome-devtools-mcp] The connecting client did not negotiate the MCP roots ' +
89
+ 'capability. File-writing tools will be restricted to the OS temp directory. ' +
90
+ 'To restore the previous unrestricted behavior, start the server with ' +
91
+ '--allow-unrestricted-paths.');
92
+ }
93
+ };
94
+ }
95
+ async connect(transport) {
96
+ return await this.server.connect(transport);
97
+ }
98
+ /**
99
+ * Closes the MCP connection and disposes internal context/listeners.
100
+ */
101
+ async close() {
102
+ this.#context?.dispose();
103
+ this.#context = undefined;
104
+ await this.server.close();
105
+ }
106
+ [Symbol.dispose]() {
107
+ this.close().catch(() => {
108
+ // TODO: wire up the logger
109
+ });
110
+ }
111
+ async [Symbol.asyncDispose]() {
112
+ await this.close();
113
+ }
114
+ static async from(serverArgs, options = {}) {
115
+ const server = new McpServer(serverArgs, options);
116
+ await server.#init();
117
+ return server;
118
+ }
119
+ async #init() {
120
+ const tools = createTools(this.#serverArgs);
121
+ for (const tool of tools) {
122
+ this.#registerTool(tool);
123
+ }
124
+ await loadIssueDescriptions();
44
125
  }
45
- const server = new McpServer({
46
- name: 'chrome_devtools',
47
- title: 'Chrome DevTools MCP server',
48
- version: VERSION,
49
- }, { capabilities: { logging: {} } });
50
- server.server.setRequestHandler(SetLevelRequestSchema, () => {
51
- return {};
52
- });
53
- // Roots are client state rather than browser state, so the last listing stays
54
- // valid across browser reconnects and only the client can invalidate it, via
55
- // the `roots/list_changed` notification handled below
56
- let lastRoots;
57
- // `timeout` is only passed where a tool call is waiting on the result – the
58
- // background refreshes below block nobody, so bounding them would just discard
59
- // roots a slow client was about to send
60
- const updateRoots = async (timeout) => {
61
- if (!server.server.getClientCapabilities()?.roots) {
126
+ #combinedRoots() {
127
+ const configuredRoots = (this.#serverArgs.allowUnrestrictedPaths
128
+ ? []
129
+ : (this.#serverArgs.filesystemRoot ?? [])).map(root => {
130
+ const rootPath = path.resolve(String(root));
131
+ return {
132
+ uri: pathToFileURL(rootPath).href,
133
+ name: path.basename(rootPath) || rootPath,
134
+ };
135
+ });
136
+ if (configuredRoots.length === 0 && this.#lastClientRoots === undefined) {
137
+ return undefined;
138
+ }
139
+ return [...configuredRoots, ...(this.#lastClientRoots ?? [])];
140
+ }
141
+ /**
142
+ * `timeout` is only passed where a tool call is waiting on the result – the
143
+ * background refreshes below block nobody, so bounding them would just discard
144
+ * roots a slow client was about to send
145
+ */
146
+ async #updateRoots(timeout) {
147
+ if (!this.server.server.getClientCapabilities()?.roots) {
62
148
  return;
63
149
  }
64
150
  try {
65
- const roots = await server.server.request({ method: 'roots/list' }, ListRootsResultSchema, timeout === undefined ? undefined : { timeout });
66
- lastRoots = roots.roots;
67
- context?.setRoots(lastRoots);
151
+ const roots = await this.server.server.request({ method: 'roots/list' }, ListRootsResultSchema, timeout === undefined ? undefined : { timeout });
152
+ this.#lastClientRoots = roots.roots;
153
+ this.#context?.setRoots(this.#combinedRoots());
68
154
  }
69
155
  catch (e) {
70
156
  logger?.('Failed to list roots', e);
71
157
  }
72
- };
73
- server.server.oninitialized = () => {
74
- const clientName = server.server.getClientVersion()?.name;
75
- if (clientName) {
76
- ClearcutLogger.get()?.setClientName(clientName);
77
- }
78
- if (server.server.getClientCapabilities()?.roots) {
79
- void updateRoots();
80
- server.server.setNotificationHandler(RootsListChangedNotificationSchema, () => {
81
- void updateRoots();
82
- });
83
- }
84
- else if (!serverArgs.allowUnrestrictedPaths) {
85
- console.warn('[chrome-devtools-mcp] The connecting client did not negotiate the MCP roots ' +
86
- 'capability. File-writing tools will be restricted to the OS temp directory. ' +
87
- 'To restore the previous unrestricted behavior, start the server with ' +
88
- '--allow-unrestricted-paths.');
89
- }
90
- };
91
- let context;
92
- async function getContext() {
93
- const devtools = serverArgs.experimentalDevtools ?? false;
94
- const blocklist = serverArgs.blockedUrlPattern
95
- ? serverArgs.blockedUrlPattern.map(String)
158
+ }
159
+ async #getContext() {
160
+ const devtools = this.#serverArgs.experimentalDevtools ?? false;
161
+ const blocklist = this.#serverArgs.blockedUrlPattern
162
+ ? this.#serverArgs.blockedUrlPattern.map(String)
96
163
  : undefined;
97
- const allowlist = serverArgs.allowedUrlPattern
98
- ? serverArgs.allowedUrlPattern.map(String)
164
+ const allowlist = this.#serverArgs.allowedUrlPattern
165
+ ? this.#serverArgs.allowedUrlPattern.map(String)
99
166
  : undefined;
100
- const browser = serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect
167
+ const channel = this.#serverArgs.channel;
168
+ const browser = this.#serverArgs.browserUrl ||
169
+ this.#serverArgs.wsEndpoint ||
170
+ this.#serverArgs.autoConnect
101
171
  ? await ensureBrowserConnected({
102
- browserURL: serverArgs.browserUrl,
103
- wsEndpoint: serverArgs.wsEndpoint,
104
- wsHeaders: serverArgs.wsHeaders,
172
+ browserURL: this.#serverArgs.browserUrl,
173
+ wsEndpoint: this.#serverArgs.wsEndpoint,
174
+ wsHeaders: this.#serverArgs.wsHeaders,
105
175
  // Important: only pass channel, if autoConnect is true.
106
- channel: serverArgs.autoConnect
107
- ? serverArgs.channel
108
- : undefined,
109
- userDataDir: serverArgs.userDataDir,
176
+ channel: this.#serverArgs.autoConnect ? channel : undefined,
177
+ userDataDir: this.#serverArgs.userDataDir,
110
178
  devtools,
111
179
  blocklist,
112
180
  allowlist,
@@ -114,52 +182,45 @@ export async function createMcpServer(serverArgs, options) {
114
182
  : await ensureBrowserLaunched(
115
183
  // Pass the already-derived values so the launched browser uses the
116
184
  // exact same flags as the connected branch and `McpContext`.
117
- buildLaunchOptions(serverArgs, options.logFile, {
185
+ buildLaunchOptions(this.#serverArgs, this.#options.logFile, {
118
186
  devtools,
119
187
  blocklist,
120
188
  allowlist,
121
189
  }));
122
- if (context?.browser !== browser) {
123
- context?.dispose();
124
- context = await McpContext.from(browser, logger, {
190
+ if (this.#context?.browser !== browser) {
191
+ this.#context?.dispose();
192
+ this.#context = await McpContext.from(browser, logger, {
125
193
  experimentalDevToolsDebugging: devtools,
126
- experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
127
- performanceCrux: serverArgs.performanceCrux,
194
+ experimentalIncludeAllPages: this.#serverArgs.experimentalIncludeAllPages,
195
+ performanceCrux: this.#serverArgs.performanceCrux,
196
+ sourceMaps: this.#serverArgs.sourceMaps,
128
197
  allowList: allowlist,
129
198
  blocklist: blocklist,
130
- allowUnrestrictedPaths: serverArgs.allowUnrestrictedPaths,
199
+ allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
131
200
  // Surfaces a one-time note in the next response after a reconnect.
132
- reconnected: context !== undefined,
201
+ reconnected: this.#context !== undefined,
202
+ categoryExtensions: this.#serverArgs.categoryExtensions,
133
203
  });
134
- if (lastRoots === undefined) {
204
+ this.#context.setRoots(this.#combinedRoots());
205
+ if (this.#lastClientRoots === undefined) {
135
206
  // Nothing listed yet, so this call has to wait – bounded, since it is
136
207
  // holding the tool mutex, and a later background refresh still lands
137
- await updateRoots(ROOTS_REQUEST_TIMEOUT);
208
+ await this.#updateRoots(ROOTS_REQUEST_TIMEOUT);
138
209
  }
139
210
  else {
140
211
  // Carry the known roots over and refresh out of band, so a reconnect
141
212
  // never pays for a client round-trip
142
- context.setRoots(lastRoots);
143
- void updateRoots();
213
+ void this.#updateRoots();
144
214
  }
145
215
  }
146
- return context;
216
+ return this.#context;
147
217
  }
148
- const operaHooks = createOperaToolHooks({
149
- serverArgs,
150
- logFile: options.logFile,
151
- resetContext: () => {
152
- context?.dispose();
153
- context = undefined;
154
- },
155
- });
156
- const toolMutex = new Mutex();
157
- function registerTool(tool) {
158
- const toolHandler = new ToolHandler(tool, serverArgs, getContext, toolMutex, operaHooks);
218
+ #registerTool(tool) {
219
+ const toolHandler = new ToolHandler(tool, this.#serverArgs, () => this.#getContext(), this.#toolMutex, this.#operaHooks);
159
220
  if (!toolHandler.shouldRegister) {
160
221
  return;
161
222
  }
162
- server.registerTool(tool.name, {
223
+ this.server.registerTool(tool.name, {
163
224
  description: tool.description,
164
225
  inputSchema: toolHandler.registeredInputSchema,
165
226
  annotations: tool.annotations,
@@ -167,12 +228,17 @@ export async function createMcpServer(serverArgs, options) {
167
228
  return await toolHandler.handle(params, extra);
168
229
  });
169
230
  }
170
- const tools = createTools(serverArgs);
171
- for (const tool of tools) {
172
- registerTool(tool);
173
- }
174
- await loadIssueDescriptions();
175
- return { server };
231
+ }
232
+ /**
233
+ * Creates and initializes a Chrome DevTools MCP server instance.
234
+ *
235
+ * Maintained as a public API for backwards compatibility because external
236
+ * consumers and integrations rely on `createMcpServer()`. For new code,
237
+ * prefer using `McpServer.from(serverArgs, options)`.
238
+ */
239
+ export async function createMcpServer(serverArgs, options = {}) {
240
+ const server = await McpServer.from(serverArgs, options);
241
+ return { server: server.server };
176
242
  }
177
243
  export const logDisclaimers = (args) => {
178
244
  console.error(BROWSER_EXPOSURE_DISCLAIMER);
@@ -15,8 +15,10 @@
15
15
  export const PACKAGE_NAME = 'opera-devtools-mcp';
16
16
  /** MCP server binary / daemon app name, also used as `process.title`. */
17
17
  export const MCP_BIN_NAME = PACKAGE_NAME;
18
- /** CLI binary name, e.g. `opera-devtools start`. */
19
- export const CLI_BIN_NAME = 'opera-devtools';
18
+ /** CLI binary name, e.g. `opera-browser-cli start`. The fork's sole CLI. */
19
+ export const CLI_BIN_NAME = 'opera-browser-cli';
20
+ /** Directory under `$HOME` holding the per-user config file (matches the CLI binary name). */
21
+ export const STATE_DIR_NAME = '.opera-browser-cli';
20
22
  /** Human-readable product name used in log lines. */
21
23
  export const PRODUCT_NAME = 'Opera DevTools MCP Server';
22
24
  /** Public repository, referenced from help text and disclaimers. */