opera-devtools-mcp 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/build/src/McpContext.js +78 -41
  2. package/build/src/McpPage.js +5 -1
  3. package/build/src/ToolHandler.js +45 -74
  4. package/build/src/bin/chrome-devtools-mcp-main.js +4 -4
  5. package/build/src/bin/chrome-devtools.js +29 -30
  6. package/build/src/bin/opera-devtools-cli-options.js +1 -1
  7. package/build/src/bin/opera-devtools-mcp-cli-options.js +1 -1
  8. package/build/src/browser.js +5 -16
  9. package/build/src/config/browser-options.js +126 -0
  10. package/build/src/config/category-options.js +81 -0
  11. package/build/src/{bin/chrome-devtools-cli-options.js → config/cli-options.js} +368 -26
  12. package/build/src/{bin/chrome-devtools-mcp-cli-options.js → config/mcp-options.js} +143 -164
  13. package/build/src/daemon/client.js +9 -0
  14. package/build/src/daemon/utils.js +6 -0
  15. package/build/src/devtools/DevtoolsUtils.js +27 -21
  16. package/build/src/formatters/NetworkFormatter.js +5 -2
  17. package/build/src/index.js +166 -100
  18. package/build/src/processors/HeapSnapshotManager.js +12 -0
  19. package/build/src/telemetry/ClearcutLogger.js +19 -6
  20. package/build/src/telemetry/transformation.js +4 -0
  21. package/build/src/telemetry/types.js +4 -0
  22. package/build/src/third_party/THIRD_PARTY_NOTICES +5 -5
  23. package/build/src/third_party/bundled-packages.json +3 -3
  24. package/build/src/third_party/devtools-formatter-worker.js +22 -0
  25. package/build/src/third_party/devtools-heap-snapshot-worker.js +100 -20
  26. package/build/src/third_party/index.js +15459 -14256
  27. package/build/src/third_party/issue-descriptions/federatedAuthRequestAccountsBlockedByConnectionAllowlist.md +1 -0
  28. package/build/src/third_party/issue-descriptions/federatedAuthRequestConfigBlockedByConnectionAllowlist.md +1 -0
  29. package/build/src/third_party/issue-descriptions/federatedAuthRequestIdTokenBlockedByConnectionAllowlist.md +1 -0
  30. package/build/src/third_party/issue-descriptions/federatedAuthRequestWellKnownBlockedByConnectionAllowlist.md +1 -0
  31. package/build/src/tools/ToolDefinition.js +15 -0
  32. package/build/src/tools/categories.js +0 -6
  33. package/build/src/tools/comments.js +90 -0
  34. package/build/src/tools/console.js +1 -1
  35. package/build/src/tools/emulation.js +1 -1
  36. package/build/src/tools/extensions.js +1 -1
  37. package/build/src/tools/memory.js +60 -6
  38. package/build/src/tools/network.js +2 -2
  39. package/build/src/tools/pages.js +26 -15
  40. package/build/src/tools/performance.js +4 -3
  41. package/build/src/tools/screencast.js +3 -2
  42. package/build/src/tools/screenshot.js +39 -8
  43. package/build/src/tools/script.js +17 -4
  44. package/build/src/tools/slim/tools.js +41 -33
  45. package/build/src/tools/snapshot.js +1 -1
  46. package/build/src/tools/tools.js +2 -0
  47. package/build/src/utils/WaitForHelper.js +12 -1
  48. package/build/src/utils/bytes.js +105 -0
  49. package/build/src/utils/url.js +73 -0
  50. package/build/src/version.js +1 -1
  51. package/package.json +23 -4
@@ -6,6 +6,7 @@
6
6
  import { zod } from '../../third_party/index.js';
7
7
  import { ToolCategory } from '../categories.js';
8
8
  import { definePageTool } from '../ToolDefinition.js';
9
+ import { validateUrl } from '../../utils/url.js';
9
10
  export const screenshot = definePageTool({
10
11
  name: 'screenshot',
11
12
  description: `Takes a screenshot`,
@@ -27,40 +28,46 @@ export const screenshot = definePageTool({
27
28
  response.appendResponseLine(filepath);
28
29
  },
29
30
  });
30
- export const navigate = definePageTool({
31
- name: 'navigate',
32
- description: `Loads a URL`,
33
- annotations: {
34
- category: ToolCategory.NAVIGATION,
35
- readOnlyHint: false,
36
- },
37
- schema: {
38
- url: zod.string().describe('URL to navigate to'),
39
- },
40
- blockedByDialog: false,
41
- verifyFilesSchema: {},
42
- handler: async (request, response) => {
43
- const page = request.page;
44
- const options = {
45
- timeout: 30_000,
46
- };
47
- const dialogHandler = (dialog) => {
48
- if (dialog.type() === 'beforeunload') {
49
- response.appendResponseLine(`Accepted a beforeunload dialog.`);
50
- void dialog.accept();
51
- // We are not going to report the dialog like regular dialogs.
52
- page.clearDialog();
31
+ export const navigate = definePageTool(args => {
32
+ return {
33
+ name: 'navigate',
34
+ description: `Loads a URL`,
35
+ annotations: {
36
+ category: ToolCategory.NAVIGATION,
37
+ readOnlyHint: false,
38
+ },
39
+ schema: {
40
+ url: zod.string().describe('URL to navigate to'),
41
+ },
42
+ blockedByDialog: false,
43
+ verifyFilesSchema: {},
44
+ handler: async (request, response) => {
45
+ validateUrl(request.params.url, {
46
+ javascriptEvaluation: args?.javascriptEvaluation,
47
+ categoryExtensions: args?.categoryExtensions,
48
+ });
49
+ const page = request.page;
50
+ const options = {
51
+ timeout: 30_000,
52
+ };
53
+ const dialogHandler = (dialog) => {
54
+ if (dialog.type() === 'beforeunload') {
55
+ response.appendResponseLine(`Accepted a beforeunload dialog.`);
56
+ void dialog.accept();
57
+ // We are not going to report the dialog like regular dialogs.
58
+ page.clearDialog();
59
+ }
60
+ };
61
+ page.pptrPage.on('dialog', dialogHandler);
62
+ try {
63
+ await page.pptrPage.goto(request.params.url, options);
64
+ response.appendResponseLine(`Navigated to ${page.pptrPage.url()}.`);
53
65
  }
54
- };
55
- page.pptrPage.on('dialog', dialogHandler);
56
- try {
57
- await page.pptrPage.goto(request.params.url, options);
58
- response.appendResponseLine(`Navigated to ${page.pptrPage.url()}.`);
59
- }
60
- finally {
61
- page.pptrPage.off('dialog', dialogHandler);
62
- }
63
- },
66
+ finally {
67
+ page.pptrPage.off('dialog', dialogHandler);
68
+ }
69
+ },
70
+ };
64
71
  });
65
72
  export const evaluate = definePageTool({
66
73
  name: 'evaluate',
@@ -68,6 +75,7 @@ export const evaluate = definePageTool({
68
75
  annotations: {
69
76
  category: ToolCategory.DEBUGGING,
70
77
  readOnlyHint: false,
78
+ conditions: ['javascriptEvaluation'],
71
79
  },
72
80
  schema: {
73
81
  script: zod.string().describe(`JS script to run on the page`),
@@ -8,7 +8,7 @@ import { ToolCategory } from './categories.js';
8
8
  import { definePageTool, timeoutSchema } from './ToolDefinition.js';
9
9
  export const takeSnapshot = definePageTool({
10
10
  name: 'take_snapshot',
11
- description: `Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique
11
+ description: `Take a text snapshot of the target page based on the a11y tree. The snapshot lists page elements along with a unique
12
12
  identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected
13
13
  in the DevTools Elements panel (if any).`,
14
14
  annotations: {
@@ -6,6 +6,7 @@
6
6
  * Modified by Opera Software AS.
7
7
  */
8
8
  import * as operaTools from '../opera/tools/opera.js';
9
+ import * as commentsTools from './comments.js';
9
10
  import * as consoleTools from './console.js';
10
11
  import * as emulationTools from './emulation.js';
11
12
  import * as extensionTools from './extensions.js';
@@ -27,6 +28,7 @@ export const createTools = (args) => {
27
28
  const rawTools = args.slim
28
29
  ? Object.values(slimTools)
29
30
  : [
31
+ ...(args.devtoolsComments ? Object.values(commentsTools) : []),
30
32
  ...Object.values(consoleTools),
31
33
  ...Object.values(emulationTools),
32
34
  ...Object.values(extensionTools),
@@ -187,7 +187,7 @@ export class WaitForHelper {
187
187
  });
188
188
  // A scoped AbortController used to clean up navigation probe listeners.
189
189
  // When aborted (either after navigation detection finishes or if this.#abortController
190
- // aborts), it removes the CDP Page.frameStartedNavigating listener and automatically
190
+ // aborts), it removes CDP navigation probe listeners and automatically
191
191
  // detaches the abort listener from this.#abortController.signal.
192
192
  const navigationAbortController = new AbortController();
193
193
  const navigationStartedResolvers = Promise.withResolvers();
@@ -201,11 +201,22 @@ export class WaitForHelper {
201
201
  }
202
202
  navigationStartedResolvers.resolve(true);
203
203
  };
204
+ const requestedNavigationListener = (event) => {
205
+ if (event.frameId === this.#page.mainFrame()._id) {
206
+ navigationStartedResolvers.resolve(true);
207
+ }
208
+ };
204
209
  this.#page._client().on('Page.frameStartedNavigating', navigationListener);
210
+ this.#page
211
+ ._client()
212
+ .on('Page.frameRequestedNavigation', requestedNavigationListener);
205
213
  navigationAbortController.signal.addEventListener('abort', () => {
206
214
  this.#page
207
215
  ._client()
208
216
  .off('Page.frameStartedNavigating', navigationListener);
217
+ this.#page
218
+ ._client()
219
+ .off('Page.frameRequestedNavigation', requestedNavigationListener);
209
220
  });
210
221
  this.#abortController.signal.addEventListener('abort', () => {
211
222
  navigationStartedResolvers.resolve(false);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { zod } from '../third_party/index.js';
7
+ const BYTE_UNITS = {
8
+ b: 1,
9
+ byte: 1,
10
+ bytes: 1,
11
+ k: 1000,
12
+ kb: 1000,
13
+ kib: 1024,
14
+ m: 1000 * 1000,
15
+ mb: 1000 * 1000,
16
+ mib: 1024 * 1024,
17
+ g: 1000 * 1000 * 1000,
18
+ gb: 1000 * 1000 * 1000,
19
+ gib: 1024 * 1024 * 1024,
20
+ t: 1000 * 1000 * 1000 * 1000,
21
+ tb: 1000 * 1000 * 1000 * 1000,
22
+ tib: 1024 * 1024 * 1024 * 1024,
23
+ };
24
+ /**
25
+ * Parses a byte size string (e.g. "1M", "1MB", "500KB", "1.5GB", "1024") into bytes.
26
+ */
27
+ export function parseByteSize(value) {
28
+ const trimmed = value.trim();
29
+ if (trimmed === '') {
30
+ throw new Error(`Invalid byte size: "${value}"`);
31
+ }
32
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/);
33
+ if (!match) {
34
+ throw new Error(`Invalid byte size format: "${value}". Expected a number or format like "1024", "1M", "1MB", "1G", "1GB".`);
35
+ }
36
+ const numMatch = match[1];
37
+ if (numMatch === undefined) {
38
+ throw new Error(`Invalid byte size: "${value}"`);
39
+ }
40
+ const num = Number(numMatch);
41
+ if (!Number.isFinite(num) || num < 0) {
42
+ throw new Error(`Invalid byte size: "${value}"`);
43
+ }
44
+ const unitMatch = match[2];
45
+ if (unitMatch === undefined) {
46
+ return Math.round(num);
47
+ }
48
+ const unit = unitMatch.toLowerCase();
49
+ const multiplier = BYTE_UNITS[unit];
50
+ if (multiplier === undefined) {
51
+ throw new Error(`Unknown unit "${unitMatch}" in "${value}". Supported units: B, KB, KiB, MB, MiB, GB, GiB, TB, TiB.`);
52
+ }
53
+ const bytes = Math.round(num * multiplier);
54
+ if (!Number.isFinite(bytes)) {
55
+ throw new Error(`Invalid byte size: "${value}"`);
56
+ }
57
+ return bytes;
58
+ }
59
+ /**
60
+ * Parses an inclusive byte-size range (e.g. "1MB", "1MB-2MB", "-1MB", "1MB-").
61
+ */
62
+ export function parseByteSizeRange(value) {
63
+ const trimmed = value.trim();
64
+ if (!trimmed.includes('-')) {
65
+ return { min: parseByteSize(trimmed), max: undefined };
66
+ }
67
+ const parts = trimmed.split('-');
68
+ if (parts.length !== 2) {
69
+ throw new Error(`Invalid byte size range: "${value}". Expected a size or range like "1MB", "1MB-2MB", "-1MB", or "1MB-".`);
70
+ }
71
+ const minValue = parts[0];
72
+ const maxValue = parts[1];
73
+ if (minValue === undefined || maxValue === undefined) {
74
+ throw new Error(`Invalid byte size range: "${value}"`);
75
+ }
76
+ const minText = minValue.trim();
77
+ const maxText = maxValue.trim();
78
+ if (minText === '' && maxText === '') {
79
+ throw new Error(`Invalid byte size range: "${value}". At least one bound is required.`);
80
+ }
81
+ const min = minText === '' ? 0 : parseByteSize(minText);
82
+ const max = maxText === '' ? undefined : parseByteSize(maxText);
83
+ if (max !== undefined && min > max) {
84
+ throw new Error(`Invalid byte size range: "${value}". The lower bound must not exceed the upper bound.`);
85
+ }
86
+ return { min, max };
87
+ }
88
+ export function byteSizeRangeSchema(description) {
89
+ return zod
90
+ .string()
91
+ .transform((value, context) => {
92
+ try {
93
+ return parseByteSizeRange(value);
94
+ }
95
+ catch (error) {
96
+ context.addIssue({
97
+ code: zod.ZodIssueCode.custom,
98
+ message: error instanceof Error ? error.message : 'Invalid byte size range',
99
+ });
100
+ return zod.NEVER;
101
+ }
102
+ })
103
+ .describe(description);
104
+ }
105
+ //# sourceMappingURL=bytes.js.map
@@ -40,4 +40,77 @@ export function isLocalhost(url) {
40
40
  }
41
41
  return false;
42
42
  }
43
+ /**
44
+ * Determines whether a URL is allowed for navigation and page tracking.
45
+ *
46
+ * Disallowed schemes:
47
+ * - chrome: (except chrome://newtab/ and chrome://inspect*)
48
+ * - chrome-untrusted:
49
+ * - chrome-extension: (unless categoryExtensions is true)
50
+ *
51
+ * @param url The URL string to test.
52
+ * @param options Configuration specifying whether extensions are enabled.
53
+ * @returns true if the URL is allowed.
54
+ */
55
+ export function isAllowedUrl(url, options) {
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(url);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ if (parsed.protocol === 'chrome:') {
64
+ const host = parsed.hostname.toLowerCase();
65
+ const path = parsed.pathname.toLowerCase();
66
+ if (host === 'newtab' || host === 'new-tab-page' || host === 'inspect') {
67
+ return true;
68
+ }
69
+ if (host === '' &&
70
+ (path === 'newtab' ||
71
+ path === 'new-tab-page' ||
72
+ path.startsWith('inspect'))) {
73
+ return true;
74
+ }
75
+ }
76
+ if (parsed.protocol === 'chrome:' ||
77
+ parsed.protocol === 'chrome-untrusted:') {
78
+ return false;
79
+ }
80
+ if (!options.categoryExtensions && parsed.protocol === 'chrome-extension:') {
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+ const DISALLOWED_PROTOCOLS = new Set(['javascript:', 'data:', 'vbscript:']);
86
+ /**
87
+ * Validates a URL string by parsing it with `new URL` and checking for disallowed protocols and restricted schemes.
88
+ *
89
+ * @param url The URL string to validate.
90
+ * @param options Options object containing javascriptEvaluation and categoryExtensions.
91
+ * @returns The parsed URL.
92
+ * @throws Error if the URL does not parse with `new URL`, or if JavaScript evaluation is disabled and a disallowed URL is passed,
93
+ * or if navigating to a restricted scheme.
94
+ */
95
+ export function validateUrl(url, options) {
96
+ const { javascriptEvaluation, categoryExtensions } = options;
97
+ let parsed;
98
+ try {
99
+ parsed = new URL(url);
100
+ }
101
+ catch {
102
+ throw new Error(`Invalid URL: "${url}". URLs must be valid according to the URL standard.`);
103
+ }
104
+ if (javascriptEvaluation === false &&
105
+ DISALLOWED_PROTOCOLS.has(parsed.protocol)) {
106
+ throw new Error(`Navigating to ${parsed.protocol} URLs is not allowed when JavaScript evaluation is disabled.`);
107
+ }
108
+ if (!isAllowedUrl(url, { categoryExtensions })) {
109
+ if (parsed.protocol === 'chrome-extension:') {
110
+ throw new Error(`Navigating to chrome-extension: URLs is not allowed without --categoryExtensions.`);
111
+ }
112
+ throw new Error(`Navigating to ${parsed.protocol} URLs is not allowed.`);
113
+ }
114
+ return parsed;
115
+ }
43
116
  //# sourceMappingURL=url.js.map
@@ -5,5 +5,5 @@
5
5
  *
6
6
  * Modified by Opera Software AS.
7
7
  */
8
- export const VERSION = '0.6.1';
8
+ export const VERSION = '0.7.0';
9
9
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opera-devtools-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for Opera DevTools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,15 +68,15 @@
68
68
  "@types/yargs": "^17.0.33",
69
69
  "@typescript-eslint/eslint-plugin": "^8.43.0",
70
70
  "@typescript-eslint/parser": "^8.43.0",
71
- "core-js": "3.49.0",
71
+ "core-js": "3.50.0",
72
72
  "eslint": "^10.7.0",
73
73
  "eslint-import-resolver-typescript": "^4.4.4",
74
74
  "eslint-plugin-import": "^2.32.0",
75
75
  "globals": "^17.0.0",
76
76
  "lighthouse": "13.4.1",
77
77
  "prettier": "^3.6.2",
78
- "puppeteer": "25.6.0",
79
- "rollup": "4.62.4",
78
+ "puppeteer": "25.10.0",
79
+ "rollup": "4.63.1",
80
80
  "rollup-plugin-cleanup": "^3.2.1",
81
81
  "rollup-plugin-license": "^3.6.0",
82
82
  "semver": "^7.7.4",
@@ -86,6 +86,18 @@
86
86
  "yargs": "18.1.0",
87
87
  "zod": "^3.25.76"
88
88
  },
89
+ "peerDependencies": {
90
+ "@blackwell-systems/gcf": "^2.2.2",
91
+ "@toon-format/toon": "^4.1.0"
92
+ },
93
+ "peerDependenciesMeta": {
94
+ "@toon-format/toon": {
95
+ "optional": true
96
+ },
97
+ "@blackwell-systems/gcf": {
98
+ "optional": true
99
+ }
100
+ },
89
101
  "engines": {
90
102
  "node": "^20.19.0 || ^22.12.0 || >=23"
91
103
  },
@@ -94,5 +106,12 @@
94
106
  "eslint": "$eslint"
95
107
  },
96
108
  "puppeteer-core": "$puppeteer"
109
+ },
110
+ "allowScripts": {
111
+ "core-js": false,
112
+ "@google/genai": false,
113
+ "protobufjs": false,
114
+ "unrs-resolver": false,
115
+ "puppeteer": true
97
116
  }
98
117
  }