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.
- package/README.md +1 -1
- package/build/src/McpContext.js +78 -41
- package/build/src/McpPage.js +5 -1
- package/build/src/ToolHandler.js +54 -76
- package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
- package/build/src/bin/chrome-devtools.js +56 -124
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +23 -25
- package/build/src/config/browser-options.js +126 -0
- package/build/src/config/category-options.js +81 -0
- package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
- package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
- package/build/src/daemon/client.js +55 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/daemon/utils.js +6 -0
- package/build/src/devtools/DevtoolsUtils.js +27 -21
- package/build/src/formatters/NetworkFormatter.js +5 -2
- package/build/src/index.js +166 -100
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/processors/HeapSnapshotManager.js +12 -0
- package/build/src/telemetry/ClearcutLogger.js +19 -6
- package/build/src/telemetry/transformation.js +4 -0
- package/build/src/telemetry/types.js +4 -0
- package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
- package/build/src/third_party/bundled-packages.json +3 -3
- package/build/src/third_party/devtools-formatter-worker.js +23 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +101 -20
- package/build/src/third_party/index.js +15460 -14256
- package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
- package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
- package/build/src/tools/ToolDefinition.js +15 -0
- package/build/src/tools/categories.js +0 -6
- package/build/src/tools/comments.js +90 -0
- package/build/src/tools/console.js +1 -1
- package/build/src/tools/emulation.js +1 -1
- package/build/src/tools/extensions.js +1 -1
- package/build/src/tools/memory.js +60 -6
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/pages.js +26 -15
- package/build/src/tools/performance.js +4 -3
- package/build/src/tools/screencast.js +3 -2
- package/build/src/tools/screenshot.js +39 -8
- package/build/src/tools/script.js +17 -4
- package/build/src/tools/slim/tools.js +41 -33
- package/build/src/tools/snapshot.js +1 -1
- package/build/src/tools/tools.js +2 -0
- package/build/src/utils/WaitForHelper.js +12 -1
- package/build/src/utils/bytes.js +105 -0
- package/build/src/utils/url.js +79 -0
- package/build/src/version.js +1 -1
- package/package.json +29 -8
- 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,
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
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 = [];
|
package/build/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
context?.setRoots(
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
void updateRoots();
|
|
213
|
+
void this.#updateRoots();
|
|
144
214
|
}
|
|
145
215
|
}
|
|
146
|
-
return context;
|
|
216
|
+
return this.#context;
|
|
147
217
|
}
|
|
148
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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-
|
|
19
|
-
export const CLI_BIN_NAME = 'opera-
|
|
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. */
|