opera-devtools-mcp 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/McpContext.js +78 -41
- package/build/src/McpPage.js +5 -1
- package/build/src/ToolHandler.js +45 -74
- package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
- package/build/src/bin/chrome-devtools.js +29 -30
- 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/browser.js +5 -16
- 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 +9 -0
- 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/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 +22 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +100 -20
- package/build/src/third_party/index.js +15459 -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 +73 -0
- package/build/src/version.js +1 -1
- package/package.json +23 -4
package/build/src/McpContext.js
CHANGED
|
@@ -15,6 +15,7 @@ import { Locator, } from './third_party/index.js';
|
|
|
15
15
|
import { listPages } from './tools/pages.js';
|
|
16
16
|
import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
|
|
17
17
|
import { getTempFilePath, resolveCanonicalPath } from './utils/files.js';
|
|
18
|
+
import { isAllowedUrl } from './utils/url.js';
|
|
18
19
|
// Page ids are handed out from a process-wide counter so they stay unique
|
|
19
20
|
// across all contexts, in particular across browser reconnects. An id issued
|
|
20
21
|
// before a reconnect then fails to resolve instead of hitting an unrelated
|
|
@@ -81,6 +82,12 @@ export class McpContext {
|
|
|
81
82
|
}
|
|
82
83
|
#onTargetCreated = async (target) => {
|
|
83
84
|
try {
|
|
85
|
+
const url = target.url();
|
|
86
|
+
if (!isAllowedUrl(url, {
|
|
87
|
+
categoryExtensions: this.#options.categoryExtensions,
|
|
88
|
+
})) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
84
91
|
const page = await target.page();
|
|
85
92
|
if (!page) {
|
|
86
93
|
return;
|
|
@@ -137,28 +144,30 @@ export class McpContext {
|
|
|
137
144
|
}
|
|
138
145
|
async validatePath(filePath) {
|
|
139
146
|
if (filePath === undefined) {
|
|
140
|
-
return;
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
let canonicalPath;
|
|
150
|
+
try {
|
|
151
|
+
canonicalPath = await resolveCanonicalPath(filePath);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
155
|
+
console.error(`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`);
|
|
156
|
+
throw new Error(`Access denied: Cannot resolve base path for ${filePath}.`);
|
|
141
157
|
}
|
|
142
158
|
// If the client never negotiated roots and the operator has explicitly
|
|
143
159
|
// opted into unrestricted access via --allow-unrestricted-paths, restore
|
|
144
160
|
// the previous permissive behavior and skip validation.
|
|
145
161
|
if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
|
|
146
|
-
|
|
162
|
+
// Canonical path might not exist yet so we fallback to
|
|
163
|
+
// path.resolve(filePath). Consumers should not follow symlinks.
|
|
164
|
+
return canonicalPath || path.resolve(filePath);
|
|
147
165
|
}
|
|
148
166
|
// roots() always returns at least the temp directory, even if the
|
|
149
167
|
// connecting client never negotiated the optional `roots` capability.
|
|
150
168
|
// Path validation must not be skipped just because no workspace roots
|
|
151
169
|
// were configured.
|
|
152
170
|
const roots = this.roots();
|
|
153
|
-
let canonicalPath;
|
|
154
|
-
try {
|
|
155
|
-
canonicalPath = await resolveCanonicalPath(filePath);
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
159
|
-
console.error(`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`);
|
|
160
|
-
throw new Error(`Access denied: Cannot resolve base path for ${filePath}.`);
|
|
161
|
-
}
|
|
162
171
|
let allowed = false;
|
|
163
172
|
const resolvedRoots = await Promise.allSettled(roots.map(async (root) => {
|
|
164
173
|
const rootPathUri = root.uri;
|
|
@@ -186,12 +195,12 @@ export class McpContext {
|
|
|
186
195
|
if (!allowed) {
|
|
187
196
|
throw new Error(`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`);
|
|
188
197
|
}
|
|
198
|
+
return canonicalPath || path.resolve(filePath);
|
|
189
199
|
}
|
|
190
200
|
async ensureExtension(filePath, extension) {
|
|
191
|
-
const
|
|
192
|
-
const currentExtension = path.extname(
|
|
193
|
-
const outputPath = `${
|
|
194
|
-
await this.validatePath(outputPath);
|
|
201
|
+
const resolved = await this.validatePath(filePath);
|
|
202
|
+
const currentExtension = path.extname(resolved);
|
|
203
|
+
const outputPath = `${resolved.slice(0, resolved.length - currentExtension.length)}${extension}`;
|
|
195
204
|
return outputPath;
|
|
196
205
|
}
|
|
197
206
|
async newPage(background, isolatedContextName) {
|
|
@@ -202,7 +211,7 @@ export class McpContext {
|
|
|
202
211
|
ctx = await this.browser.createBrowserContext();
|
|
203
212
|
this.#isolatedContexts.set(isolatedContextName, ctx);
|
|
204
213
|
}
|
|
205
|
-
page = await ctx.newPage();
|
|
214
|
+
page = await ctx.newPage({ background });
|
|
206
215
|
}
|
|
207
216
|
else {
|
|
208
217
|
page = await this.browser.newPage({ background });
|
|
@@ -266,6 +275,21 @@ export class McpContext {
|
|
|
266
275
|
}
|
|
267
276
|
return page;
|
|
268
277
|
}
|
|
278
|
+
getSelectedMcpPageUrl(page) {
|
|
279
|
+
let targetPage = page;
|
|
280
|
+
if (!targetPage) {
|
|
281
|
+
try {
|
|
282
|
+
targetPage = this.getSelectedMcpPage();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (targetPage?.pptrPage?.isClosed() === false) {
|
|
289
|
+
return targetPage.pptrPage.url();
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
269
293
|
async getDevToolsData(page) {
|
|
270
294
|
const targetPage = page ?? this.#selectedPage;
|
|
271
295
|
if (!targetPage) {
|
|
@@ -372,6 +396,7 @@ export class McpContext {
|
|
|
372
396
|
hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
|
|
373
397
|
isolatedContextName: this.#getBrowserContextToNameMap().get(page.browserContext()),
|
|
374
398
|
navigationTimeout: this.#options.navigationTimeout,
|
|
399
|
+
sourceMaps: this.#options.sourceMaps,
|
|
375
400
|
});
|
|
376
401
|
this.#mcpPages.set(page, mcpPage);
|
|
377
402
|
await mcpPage.init();
|
|
@@ -409,29 +434,38 @@ export class McpContext {
|
|
|
409
434
|
}
|
|
410
435
|
async #fetchBrowserPages() {
|
|
411
436
|
const allPages = (await this.browser.pages(this.#options.experimentalIncludeAllPages)).filter(page => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
437
|
+
if (!this.#options.experimentalDevToolsDebugging &&
|
|
438
|
+
page.url().startsWith('devtools://')) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return isAllowedUrl(page.url(), {
|
|
442
|
+
categoryExtensions: this.#options.categoryExtensions,
|
|
443
|
+
});
|
|
419
444
|
});
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
445
|
+
if (this.#options.categoryExtensions) {
|
|
446
|
+
const allTargets = this.browser.targets();
|
|
447
|
+
const extensionTargets = allTargets.filter(target => {
|
|
448
|
+
return (target.url().startsWith('chrome-extension://') &&
|
|
449
|
+
target.type() === 'page');
|
|
450
|
+
});
|
|
451
|
+
await Promise.allSettled(extensionTargets.map(async (target) => {
|
|
452
|
+
try {
|
|
453
|
+
let page = await target.page();
|
|
454
|
+
if (!page) {
|
|
455
|
+
page = await target.asPage();
|
|
456
|
+
}
|
|
457
|
+
this.#extensionPages.set(target, page);
|
|
458
|
+
if (page &&
|
|
459
|
+
isAllowedUrl(page.url(), { categoryExtensions: true }) &&
|
|
460
|
+
!allPages.includes(page)) {
|
|
461
|
+
allPages.push(page);
|
|
462
|
+
}
|
|
425
463
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
allPages.push(page);
|
|
464
|
+
catch (e) {
|
|
465
|
+
this.logger?.('Failed to get page for extension target', e);
|
|
429
466
|
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
this.logger?.('Failed to get page for extension target', e);
|
|
433
|
-
}
|
|
434
|
-
}));
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
435
469
|
return allPages;
|
|
436
470
|
}
|
|
437
471
|
getExtensionServiceWorkers() {
|
|
@@ -441,16 +475,16 @@ export class McpContext {
|
|
|
441
475
|
return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
|
|
442
476
|
}
|
|
443
477
|
async #writeFile(filepath, data) {
|
|
444
|
-
await this.validatePath(filepath);
|
|
478
|
+
const resolved = await this.validatePath(filepath);
|
|
445
479
|
try {
|
|
446
|
-
await fs.mkdir(path.dirname(
|
|
480
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
447
481
|
// Open the file with flags to:
|
|
448
482
|
// - O_WRONLY: Write-only
|
|
449
483
|
// - O_CREAT: Create if it doesn't exist
|
|
450
484
|
// - O_TRUNC: Truncate to zero length if it exists
|
|
451
485
|
// - O_NOFOLLOW: DO NOT follow symlinks.
|
|
452
486
|
// - 0o600: Permissions: read/write for owner, no permissions for others.
|
|
453
|
-
await fs.writeFile(
|
|
487
|
+
await fs.writeFile(resolved, data, {
|
|
454
488
|
flag: fs.constants.O_WRONLY |
|
|
455
489
|
fs.constants.O_CREAT |
|
|
456
490
|
fs.constants.O_TRUNC |
|
|
@@ -515,6 +549,9 @@ export class McpContext {
|
|
|
515
549
|
async getHeapSnapshotDuplicateStrings(filePath) {
|
|
516
550
|
return await this.#heapSnapshotManager.getDuplicateStrings(filePath);
|
|
517
551
|
}
|
|
552
|
+
async queryHeapSnapshotObjects(filePath, options) {
|
|
553
|
+
return await this.#heapSnapshotManager.queryObjects(filePath, options);
|
|
554
|
+
}
|
|
518
555
|
async getHeapSnapshotStats(filePath) {
|
|
519
556
|
return await this.#heapSnapshotManager.getStats(filePath);
|
|
520
557
|
}
|
|
@@ -585,8 +622,8 @@ export class McpContext {
|
|
|
585
622
|
return response.text();
|
|
586
623
|
}
|
|
587
624
|
case 'file:': {
|
|
588
|
-
await this.validatePath(fileURLToPath(url));
|
|
589
|
-
return await fs.readFile(
|
|
625
|
+
const resolved = await this.validatePath(fileURLToPath(url));
|
|
626
|
+
return await fs.readFile(resolved, 'utf-8');
|
|
590
627
|
}
|
|
591
628
|
default:
|
|
592
629
|
throw new Error(`Unsupported protocol for: ${url}`);
|
package/build/src/McpPage.js
CHANGED
|
@@ -139,10 +139,12 @@ export class McpPage {
|
|
|
139
139
|
#hasNetworkBlockOrAllowlist;
|
|
140
140
|
#locatorClass;
|
|
141
141
|
#navigationTimeout;
|
|
142
|
+
#sourceMaps;
|
|
142
143
|
constructor(page, id, options) {
|
|
143
144
|
this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist;
|
|
144
145
|
this.#locatorClass = options.locatorClass;
|
|
145
146
|
this.#navigationTimeout = options.navigationTimeout ?? NAVIGATION_TIMEOUT;
|
|
147
|
+
this.#sourceMaps = options.sourceMaps ?? true;
|
|
146
148
|
this.pptrPage = page;
|
|
147
149
|
this.id = id;
|
|
148
150
|
this.isolatedContextName = options.isolatedContextName;
|
|
@@ -183,7 +185,9 @@ export class McpPage {
|
|
|
183
185
|
}
|
|
184
186
|
try {
|
|
185
187
|
const session = await this.pptrPage.createCDPSession();
|
|
186
|
-
this.#devtoolsUniverse = await createTargetUniverse(session
|
|
188
|
+
this.#devtoolsUniverse = await createTargetUniverse(session, {
|
|
189
|
+
sourceMaps: this.#sourceMaps,
|
|
190
|
+
});
|
|
187
191
|
}
|
|
188
192
|
catch (e) {
|
|
189
193
|
logger?.('Failed to initialize DevTools universe', e);
|
package/build/src/ToolHandler.js
CHANGED
|
@@ -11,65 +11,35 @@ import { McpResponse } from './McpResponse.js';
|
|
|
11
11
|
import { CLI_BIN_NAME } from './opera/branding.js';
|
|
12
12
|
import { SlimMcpResponse } from './SlimMcpResponse.js';
|
|
13
13
|
import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
|
|
14
|
-
import { bucketizeLatency, buildContext } from './telemetry/transformation.js';
|
|
15
14
|
import { zod } from './third_party/index.js';
|
|
16
|
-
import { labels
|
|
15
|
+
import { labels } from './tools/categories.js';
|
|
16
|
+
import { categoryToFlagName } from './config/category-options.js';
|
|
17
17
|
import { pageIdSchema } from './tools/ToolDefinition.js';
|
|
18
18
|
import { logger } from './utils/logger.js';
|
|
19
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
import { isLocalhost } from './utils/url.js';
|
|
21
|
-
export function buildFlag(category) {
|
|
22
|
-
return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
|
|
23
|
-
}
|
|
24
21
|
function buildDisabledMessage(toolName, flag, categoryLabel) {
|
|
25
22
|
const reason = categoryLabel
|
|
26
23
|
? `is in category ${categoryLabel} which`
|
|
27
|
-
: `requires experimental feature ${flag} and`;
|
|
24
|
+
: `requires ${flag.startsWith('--experimental') ? 'experimental feature' : 'flag'} ${flag} and`;
|
|
28
25
|
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running ${CLI_BIN_NAME} start ${flag}=true. For more information check the README.`;
|
|
29
26
|
}
|
|
30
|
-
function getCategoryStatus(category, serverArgs) {
|
|
31
|
-
const categoryFlag = buildFlag(category);
|
|
32
|
-
const flagValue = serverArgs[categoryFlag];
|
|
33
|
-
const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category)
|
|
34
|
-
? !flagValue
|
|
35
|
-
: flagValue === false;
|
|
36
|
-
if (isDisabled) {
|
|
37
|
-
return {
|
|
38
|
-
categoryFlag,
|
|
39
|
-
disabled: true,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
disabled: false,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
function getConditionStatus(condition, serverArgs) {
|
|
47
|
-
if (condition && !serverArgs[condition]) {
|
|
48
|
-
return { conditionFlag: condition, disabled: true };
|
|
49
|
-
}
|
|
50
|
-
return { disabled: false };
|
|
51
|
-
}
|
|
52
27
|
function getToolStatusInfo(tool, serverArgs) {
|
|
53
28
|
const category = tool.annotations.category;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!
|
|
57
|
-
|
|
29
|
+
if (category) {
|
|
30
|
+
const flag = categoryToFlagName(category);
|
|
31
|
+
if (!serverArgs[flag]) {
|
|
32
|
+
return {
|
|
33
|
+
disabled: true,
|
|
34
|
+
reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]),
|
|
35
|
+
};
|
|
58
36
|
}
|
|
59
|
-
return {
|
|
60
|
-
disabled: true,
|
|
61
|
-
reason: buildDisabledMessage(tool.name, `--${categoryCheck.categoryFlag}`, labels[category]),
|
|
62
|
-
};
|
|
63
37
|
}
|
|
64
38
|
for (const condition of tool.annotations.conditions || []) {
|
|
65
|
-
|
|
66
|
-
if (conditionCheck.disabled) {
|
|
67
|
-
if (!conditionCheck.conditionFlag) {
|
|
68
|
-
throw new Error('when the condition is disabled there should always be a flag set');
|
|
69
|
-
}
|
|
39
|
+
if (!serverArgs[condition]) {
|
|
70
40
|
return {
|
|
71
41
|
disabled: true,
|
|
72
|
-
reason: buildDisabledMessage(tool.name, `--${
|
|
42
|
+
reason: buildDisabledMessage(tool.name, `--${condition}`),
|
|
73
43
|
};
|
|
74
44
|
}
|
|
75
45
|
}
|
|
@@ -89,14 +59,20 @@ function buildUnknownArgumentsMessage(toolName, unknownArgumentNames, expectedAr
|
|
|
89
59
|
const correction = unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them';
|
|
90
60
|
return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`;
|
|
91
61
|
}
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
62
|
+
async function validateAndResolvePathOrUrl(filePathOrUrl, context) {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(filePathOrUrl);
|
|
65
|
+
if (url.protocol === 'file:') {
|
|
66
|
+
return pathToFileURL(await context.validatePath(fileURLToPath(url))).href;
|
|
67
|
+
}
|
|
68
|
+
else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
|
|
69
|
+
return filePathOrUrl;
|
|
70
|
+
}
|
|
95
71
|
}
|
|
96
|
-
|
|
97
|
-
|
|
72
|
+
catch {
|
|
73
|
+
// Suppress parsing errors for regular file paths.
|
|
98
74
|
}
|
|
99
|
-
return
|
|
75
|
+
return await context.validatePath(filePathOrUrl);
|
|
100
76
|
}
|
|
101
77
|
function isLocalBrowser(context) {
|
|
102
78
|
if (context.browser.process()) {
|
|
@@ -122,27 +98,25 @@ function shouldValidateFile(option, isLocal) {
|
|
|
122
98
|
}
|
|
123
99
|
async function validateToolFiles(tool, params, context) {
|
|
124
100
|
const isLocal = isLocalBrowser(context);
|
|
125
|
-
const pathsOrUrlsToValidate = [];
|
|
126
101
|
for (const [key, option] of Object.entries(tool.verifyFilesSchema)) {
|
|
127
102
|
if (shouldValidateFile(option, isLocal)) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for (const filePathOrUrl of pathsOrUrlsToValidate) {
|
|
132
|
-
let filePath = filePathOrUrl;
|
|
133
|
-
try {
|
|
134
|
-
const url = new URL(filePathOrUrl);
|
|
135
|
-
if (url.protocol === 'file:') {
|
|
136
|
-
filePath = fileURLToPath(url);
|
|
103
|
+
const val = params[key];
|
|
104
|
+
if (typeof val === 'string') {
|
|
105
|
+
params[key] = await validateAndResolvePathOrUrl(val, context);
|
|
137
106
|
}
|
|
138
|
-
else if (
|
|
139
|
-
|
|
107
|
+
else if (Array.isArray(val)) {
|
|
108
|
+
const updated = [];
|
|
109
|
+
for (const item of val) {
|
|
110
|
+
if (typeof item === 'string') {
|
|
111
|
+
updated.push(await validateAndResolvePathOrUrl(item, context));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new Error('Unexpected non-string value as a file path or URL');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
params[key] = updated;
|
|
140
118
|
}
|
|
141
119
|
}
|
|
142
|
-
catch {
|
|
143
|
-
// Suppress parsing errors for regular file paths.
|
|
144
|
-
}
|
|
145
|
-
await context.validatePath(filePath);
|
|
146
120
|
}
|
|
147
121
|
}
|
|
148
122
|
export class ToolHandler {
|
|
@@ -167,7 +141,7 @@ export class ToolHandler {
|
|
|
167
141
|
this.inputSchema =
|
|
168
142
|
'pageScoped' in tool &&
|
|
169
143
|
tool.pageScoped &&
|
|
170
|
-
serverArgs.
|
|
144
|
+
serverArgs.pageIdRouting &&
|
|
171
145
|
!serverArgs.slim
|
|
172
146
|
? { ...pageIdSchema, ...tool.schema }
|
|
173
147
|
: tool.schema;
|
|
@@ -226,7 +200,7 @@ export class ToolHandler {
|
|
|
226
200
|
if (isPageScopedTool(this.tool)) {
|
|
227
201
|
const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
|
|
228
202
|
page =
|
|
229
|
-
this.serverArgs.
|
|
203
|
+
this.serverArgs.pageIdRouting &&
|
|
230
204
|
pageId !== undefined &&
|
|
231
205
|
!this.serverArgs.slim
|
|
232
206
|
? context.getPageById(pageId)
|
|
@@ -252,10 +226,7 @@ export class ToolHandler {
|
|
|
252
226
|
response.setError(err);
|
|
253
227
|
}
|
|
254
228
|
devToolsData = await context.getDevToolsData(page);
|
|
255
|
-
|
|
256
|
-
if (targetPage?.pptrPage?.isClosed() === false) {
|
|
257
|
-
pageUrl = targetPage.pptrPage.url();
|
|
258
|
-
}
|
|
229
|
+
pageUrl = context.getSelectedMcpPageUrl(page);
|
|
259
230
|
// Resolve data format: --experimentalDataFormat takes precedence, fall back to legacy --experimentalToonFormat
|
|
260
231
|
let dataFormat = 'default';
|
|
261
232
|
if (this.serverArgs.experimentalDataFormat) {
|
|
@@ -294,14 +265,14 @@ export class ToolHandler {
|
|
|
294
265
|
};
|
|
295
266
|
}
|
|
296
267
|
finally {
|
|
297
|
-
const context = buildContext(devToolsData, pageUrl);
|
|
298
268
|
void ClearcutLogger.get()?.logToolInvocation({
|
|
299
269
|
toolName: this.tool.name,
|
|
300
270
|
params,
|
|
301
271
|
schema: this.inputSchema,
|
|
302
272
|
success,
|
|
303
|
-
latencyMs:
|
|
304
|
-
|
|
273
|
+
latencyMs: Date.now() - startTime,
|
|
274
|
+
devToolsData,
|
|
275
|
+
pageUrl,
|
|
305
276
|
});
|
|
306
277
|
guard?.[Symbol.dispose]();
|
|
307
278
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import '../utils/polyfill.js';
|
|
9
9
|
import process from 'node:process';
|
|
10
10
|
import { closeBrowser } from '../browser.js';
|
|
11
|
-
import {
|
|
11
|
+
import { McpServer, logDisclaimers } from '../index.js';
|
|
12
12
|
import { ENV_CRASH_ON_UNCAUGHT, PACKAGE_NAME, PRODUCT_NAME, } from '../opera/branding.js';
|
|
13
13
|
import { enforceTelemetryPolicy } from '../opera/policy.js';
|
|
14
14
|
import { ClearcutLogger } from '../telemetry/ClearcutLogger.js';
|
|
@@ -17,7 +17,7 @@ import { StdioServerTransport } from '../third_party/index.js';
|
|
|
17
17
|
import { checkForUpdates } from '../utils/check-for-updates.js';
|
|
18
18
|
import { logger, saveLogsToFile } from '../utils/logger.js';
|
|
19
19
|
import { VERSION } from '../version.js';
|
|
20
|
-
import {
|
|
20
|
+
import { mcpOptions, parseArguments } from '../config/mcp-options.js';
|
|
21
21
|
await checkForUpdates(`Run \`npm install ${PACKAGE_NAME}@latest\` to update.`);
|
|
22
22
|
export const args = parseArguments(VERSION);
|
|
23
23
|
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
|
|
@@ -65,7 +65,7 @@ process.on('SIGINT', () => {
|
|
|
65
65
|
process.on('SIGHUP', () => {
|
|
66
66
|
void shutdown('SIGHUP');
|
|
67
67
|
});
|
|
68
|
-
const
|
|
68
|
+
const server = await McpServer.from(args, {
|
|
69
69
|
logFile,
|
|
70
70
|
});
|
|
71
71
|
const transport = new StdioServerTransport();
|
|
@@ -73,5 +73,5 @@ await server.connect(transport);
|
|
|
73
73
|
logger?.(`${PRODUCT_NAME} connected`);
|
|
74
74
|
logDisclaimers(args);
|
|
75
75
|
void ClearcutLogger.get()?.logDailyActiveIfNeeded();
|
|
76
|
-
void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args,
|
|
76
|
+
void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, mcpOptions));
|
|
77
77
|
//# sourceMappingURL=chrome-devtools-mcp-main.js.map
|
|
@@ -14,35 +14,27 @@ import { CLI_BIN_NAME, MCP_BIN_NAME, PACKAGE_NAME } from '../opera/branding.js';
|
|
|
14
14
|
import { hideBin, yargs } from '../third_party/index.js';
|
|
15
15
|
import { checkForUpdates } from '../utils/check-for-updates.js';
|
|
16
16
|
import { VERSION } from '../version.js';
|
|
17
|
-
import { commands } from '
|
|
18
|
-
import {
|
|
17
|
+
import { commands } from '../config/cli-options.js';
|
|
18
|
+
import { mcpOptions, parseArguments, getMcpOptionsForViaCli, } from '../config/mcp-options.js';
|
|
19
19
|
process.title = CLI_BIN_NAME;
|
|
20
20
|
await checkForUpdates(`Run \`npm install -g ${PACKAGE_NAME}@latest\` and \`${CLI_BIN_NAME} start\` to update and restart the daemon.`);
|
|
21
|
+
const DEFAULT_CLI_ARGS = ['--viaCli'];
|
|
21
22
|
async function start(args, sessionId) {
|
|
22
|
-
const combinedArgs = [...
|
|
23
|
+
const combinedArgs = [...DEFAULT_CLI_ARGS, ...args];
|
|
23
24
|
await startDaemon(combinedArgs, sessionId);
|
|
24
25
|
logDisclaimers(parseArguments(VERSION, combinedArgs));
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
// Missing CLI serialization.
|
|
31
|
-
delete
|
|
32
|
-
// Change the defaults for the CLI.
|
|
33
|
-
delete
|
|
34
|
-
delete
|
|
35
|
-
|
|
36
|
-
if (!('default' in cliOptions.headless)) {
|
|
37
|
-
throw new Error('headless cli option unexpectedly does not have a default');
|
|
27
|
+
function getCliOptions() {
|
|
28
|
+
const options = {
|
|
29
|
+
...getMcpOptionsForViaCli(),
|
|
30
|
+
};
|
|
31
|
+
// Missing CLI serialization.
|
|
32
|
+
delete options.viewport;
|
|
33
|
+
// Change the defaults for the CLI.
|
|
34
|
+
delete options.experimentalStructuredContent;
|
|
35
|
+
delete options.experimentalInteropTools;
|
|
36
|
+
return options;
|
|
38
37
|
}
|
|
39
|
-
if ('default' in cliOptions.isolated) {
|
|
40
|
-
throw new Error('isolated cli option unexpectedly has a default');
|
|
41
|
-
}
|
|
42
|
-
startCliOptions.headless.default = true;
|
|
43
|
-
startCliOptions.isolated.description =
|
|
44
|
-
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
|
|
45
|
-
startCliOptions.categoryExtensions.default = true;
|
|
46
38
|
const y = yargs(hideBin(process.argv))
|
|
47
39
|
.locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways
|
|
48
40
|
.scriptName(CLI_BIN_NAME)
|
|
@@ -74,9 +66,9 @@ const y = yargs(hideBin(process.argv))
|
|
|
74
66
|
console.error('💡 TIP FOR AI AGENT / DEVELOPER:');
|
|
75
67
|
console.error('In the `chrome-devtools` CLI:');
|
|
76
68
|
console.error('1. Required parameters MUST be passed as positional arguments (without flags).');
|
|
77
|
-
console.error(' - INCORRECT: chrome-devtools
|
|
78
|
-
console.error(' - CORRECT: chrome-devtools
|
|
79
|
-
console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --
|
|
69
|
+
console.error(' - INCORRECT: chrome-devtools click --pageId 1 --uid "1_2"');
|
|
70
|
+
console.error(' - CORRECT: chrome-devtools click 1 "1_2"');
|
|
71
|
+
console.error('2. Optional parameters are passed as double-dash options/flags (e.g. --dblClick true).');
|
|
80
72
|
console.error('3. Make sure to escape quotes properly for your shell environment.');
|
|
81
73
|
console.error('Run `chrome-devtools <command> --help` to see exact positional and optional parameters.');
|
|
82
74
|
console.error('=========================================');
|
|
@@ -88,20 +80,27 @@ const y = yargs(hideBin(process.argv))
|
|
|
88
80
|
process.exit(1);
|
|
89
81
|
});
|
|
90
82
|
y.command('start', `Start or restart ${MCP_BIN_NAME}`, y => y
|
|
91
|
-
.options(
|
|
83
|
+
.options(getCliOptions())
|
|
92
84
|
.example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
|
|
93
85
|
.strict(), async (argv) => {
|
|
94
86
|
if (isDaemonRunning(argv.sessionId)) {
|
|
95
87
|
await stopDaemon(argv.sessionId);
|
|
96
88
|
}
|
|
97
89
|
// Defaults but we do not want to affect the yargs conflict resolution.
|
|
98
|
-
if (argv.isolated === undefined &&
|
|
90
|
+
if (argv.isolated === undefined &&
|
|
91
|
+
argv.userDataDir === undefined &&
|
|
92
|
+
!argv.autoConnect &&
|
|
93
|
+
!argv.browserUrl &&
|
|
94
|
+
!argv.wsEndpoint) {
|
|
99
95
|
argv.isolated = true;
|
|
100
96
|
}
|
|
101
|
-
if (argv.headless === undefined
|
|
97
|
+
if (argv.headless === undefined &&
|
|
98
|
+
!argv.autoConnect &&
|
|
99
|
+
!argv.browserUrl &&
|
|
100
|
+
!argv.wsEndpoint) {
|
|
102
101
|
argv.headless = true;
|
|
103
102
|
}
|
|
104
|
-
const args = serializeArgs(
|
|
103
|
+
const args = serializeArgs(mcpOptions, argv);
|
|
105
104
|
await start(args, argv.sessionId);
|
|
106
105
|
process.exit(0);
|
|
107
106
|
}).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
|
|
@@ -195,7 +194,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
|
|
|
195
194
|
? verifyDaemonVersion(sessionId, VERSION)
|
|
196
195
|
: Promise.resolve(undefined);
|
|
197
196
|
if (!isDaemonRunning(sessionId)) {
|
|
198
|
-
await start(serializeArgs(
|
|
197
|
+
await start(serializeArgs(mcpOptions, argv), sessionId);
|
|
199
198
|
}
|
|
200
199
|
const commandArgs = {};
|
|
201
200
|
for (const argName of Object.keys(args)) {
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
// Compatibility shim. The canonical file is generated by `npm run cli:generate`.
|
|
7
|
-
export * from '
|
|
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 '
|
|
7
|
+
export * from '../config/mcp-options.js';
|
|
8
8
|
//# sourceMappingURL=opera-devtools-mcp-cli-options.js.map
|
package/build/src/browser.js
CHANGED
|
@@ -11,27 +11,16 @@ import os from 'node:os';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { puppeteer } from './third_party/index.js';
|
|
13
13
|
import { logger, puppeteerLogger } from './utils/logger.js';
|
|
14
|
+
import { isAllowedUrl } from './utils/url.js';
|
|
14
15
|
let browser;
|
|
15
16
|
let browserMode;
|
|
16
|
-
function makeTargetFilter(enableExtensions = false) {
|
|
17
|
-
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
|
|
18
|
-
if (!enableExtensions) {
|
|
19
|
-
ignoredPrefixes.add('chrome-extension://');
|
|
20
|
-
}
|
|
17
|
+
export function makeTargetFilter(enableExtensions = false) {
|
|
21
18
|
return function targetFilter(target) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
// Could be the only page opened in the browser.
|
|
26
|
-
if (target.url().startsWith('chrome://inspect')) {
|
|
19
|
+
const url = target.url();
|
|
20
|
+
if (!url) {
|
|
27
21
|
return true;
|
|
28
22
|
}
|
|
29
|
-
|
|
30
|
-
if (target.url().startsWith(prefix)) {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return true;
|
|
23
|
+
return isAllowedUrl(url, { categoryExtensions: enableExtensions });
|
|
35
24
|
};
|
|
36
25
|
}
|
|
37
26
|
export async function ensureBrowserConnected(options) {
|