chrome-devtools-mcp 0.26.0 → 1.1.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 +59 -10
- package/build/src/DevtoolsUtils.js +14 -1
- package/build/src/HeapSnapshotManager.js +42 -18
- package/build/src/McpContext.js +61 -23
- package/build/src/McpResponse.js +51 -21
- package/build/src/ToolHandler.js +30 -1
- package/build/src/WaitForHelper.js +18 -4
- package/build/src/bin/check-latest-version.js +25 -1
- package/build/src/bin/chrome-devtools-cli-options.js +81 -39
- package/build/src/bin/chrome-devtools-mcp-cli-options.js +2 -8
- package/build/src/bin/chrome-devtools-mcp-main.js +38 -0
- package/build/src/browser.js +36 -2
- package/build/src/daemon/client.js +12 -6
- package/build/src/daemon/daemon.js +62 -5
- package/build/src/formatters/HeapSnapshotFormatter.js +30 -9
- package/build/src/index.js +3 -1
- package/build/src/telemetry/ClearcutLogger.js +8 -119
- package/build/src/telemetry/errors.js +4 -0
- package/build/src/telemetry/flagUtils.js +4 -3
- package/build/src/telemetry/{toolMetricsUtils.js → metricsRegistry.js} +3 -3
- package/build/src/telemetry/persistence.js +20 -2
- package/build/src/telemetry/transformation.js +134 -0
- package/build/src/third_party/THIRD_PARTY_NOTICES +4 -719
- package/build/src/third_party/bundled-packages.json +2 -2
- package/build/src/third_party/devtools-formatter-worker.js +447 -114
- package/build/src/third_party/devtools-heap-snapshot-worker.js +2 -3
- package/build/src/third_party/index.js +3443 -30153
- package/build/src/third_party/issue-descriptions/genericBackUINavigationWouldSkipAd.md +4 -0
- package/build/src/tools/ToolDefinition.js +2 -2
- package/build/src/tools/emulation.js +28 -2
- package/build/src/tools/extensions.js +2 -0
- package/build/src/tools/input.js +19 -10
- package/build/src/tools/lighthouse.js +1 -1
- package/build/src/tools/memory.js +39 -17
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/performance.js +9 -6
- package/build/src/tools/screencast.js +1 -1
- package/build/src/tools/screenshot.js +1 -1
- package/build/src/tools/script.js +32 -10
- package/build/src/tools/snapshot.js +1 -1
- package/build/src/trace-processing/parse.js +2 -2
- package/build/src/utils/files.js +43 -0
- package/build/src/version.js +1 -1
- package/package.json +7 -4
- package/build/src/telemetry/metricUtils.js +0 -15
|
@@ -11,12 +11,15 @@ export class WaitForHelper {
|
|
|
11
11
|
#stableDomFor;
|
|
12
12
|
#expectNavigationIn;
|
|
13
13
|
#navigationTimeout;
|
|
14
|
+
#dialogOpened = false;
|
|
15
|
+
#initialUrl;
|
|
14
16
|
constructor(page, cpuTimeoutMultiplier, networkTimeoutMultiplier) {
|
|
15
17
|
this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier;
|
|
16
18
|
this.#stableDomFor = 100 * cpuTimeoutMultiplier;
|
|
17
19
|
this.#expectNavigationIn = 100 * cpuTimeoutMultiplier;
|
|
18
20
|
this.#navigationTimeout = 3000 * networkTimeoutMultiplier;
|
|
19
21
|
this.#page = page;
|
|
22
|
+
this.#initialUrl = page.url();
|
|
20
23
|
}
|
|
21
24
|
/**
|
|
22
25
|
* A wrapper that executes a action and waits for
|
|
@@ -104,10 +107,12 @@ export class WaitForHelper {
|
|
|
104
107
|
});
|
|
105
108
|
}
|
|
106
109
|
async waitForEventsAfterAction(action, options) {
|
|
107
|
-
|
|
110
|
+
if (this.#abortController.signal.aborted) {
|
|
111
|
+
throw new Error("Can't re-use a WaitForHelper");
|
|
112
|
+
}
|
|
108
113
|
if (options?.handleDialog) {
|
|
109
114
|
const dialogHandler = (dialog) => {
|
|
110
|
-
dialogOpened = true;
|
|
115
|
+
this.#dialogOpened = true;
|
|
111
116
|
if (options.handleDialog === 'dismiss') {
|
|
112
117
|
void dialog.dismiss();
|
|
113
118
|
}
|
|
@@ -144,8 +149,8 @@ export class WaitForHelper {
|
|
|
144
149
|
}
|
|
145
150
|
try {
|
|
146
151
|
await navigationFinished;
|
|
147
|
-
if (dialogOpened) {
|
|
148
|
-
return;
|
|
152
|
+
if (this.#dialogOpened) {
|
|
153
|
+
return this.#getResult();
|
|
149
154
|
}
|
|
150
155
|
// Wait for stable dom after navigation so we execute in
|
|
151
156
|
// the correct context
|
|
@@ -157,6 +162,15 @@ export class WaitForHelper {
|
|
|
157
162
|
finally {
|
|
158
163
|
this.#abortController.abort();
|
|
159
164
|
}
|
|
165
|
+
return this.#getResult();
|
|
166
|
+
}
|
|
167
|
+
#getResult() {
|
|
168
|
+
const urlAfterAction = this.#page.url();
|
|
169
|
+
return {
|
|
170
|
+
...(urlAfterAction !== this.#initialUrl
|
|
171
|
+
? { navigatedToUrl: urlAfterAction }
|
|
172
|
+
: {}),
|
|
173
|
+
};
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
176
|
export function getNetworkMultiplierFromString(condition) {
|
|
@@ -3,13 +3,37 @@
|
|
|
3
3
|
* Copyright 2026 Google LLC
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
6
7
|
import fs from 'node:fs/promises';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import process from 'node:process';
|
|
10
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
11
|
+
function getRegistry() {
|
|
12
|
+
// Use the user's configured npm registry so update checks work behind
|
|
13
|
+
// corporate proxies and private registries. `npm config get registry`
|
|
14
|
+
// honors .npmrc files at every scope and respects npm_config_registry,
|
|
15
|
+
// so it covers direct CLI invocations as well as `npx` / `npm run`.
|
|
16
|
+
try {
|
|
17
|
+
const registry = execSync('npm config get registry', {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
})
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/\/$/, '');
|
|
24
|
+
if (registry && registry !== 'undefined' && /^https?:\/\//.test(registry)) {
|
|
25
|
+
return registry;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// npm not on PATH or other errors, fall back to default.
|
|
30
|
+
}
|
|
31
|
+
return DEFAULT_REGISTRY;
|
|
32
|
+
}
|
|
9
33
|
const cachePath = process.argv[2];
|
|
10
34
|
if (cachePath) {
|
|
11
35
|
try {
|
|
12
|
-
const response = await fetch(
|
|
36
|
+
const response = await fetch(`${getRegistry()}/chrome-devtools-mcp/latest`);
|
|
13
37
|
const data = response.ok ? await response.json() : null;
|
|
14
38
|
if (data &&
|
|
15
39
|
typeof data === 'object' &&
|
|
@@ -114,7 +114,7 @@ export const commands = {
|
|
|
114
114
|
geolocation: {
|
|
115
115
|
name: 'geolocation',
|
|
116
116
|
type: 'string',
|
|
117
|
-
description: 'Geolocation (`<latitude
|
|
117
|
+
description: 'Geolocation (`<latitude>,<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.',
|
|
118
118
|
required: false,
|
|
119
119
|
},
|
|
120
120
|
userAgent: {
|
|
@@ -136,6 +136,12 @@ export const commands = {
|
|
|
136
136
|
description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
|
|
137
137
|
required: false,
|
|
138
138
|
},
|
|
139
|
+
extraHttpHeaders: {
|
|
140
|
+
name: 'extraHttpHeaders',
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.',
|
|
143
|
+
required: false,
|
|
144
|
+
},
|
|
139
145
|
},
|
|
140
146
|
},
|
|
141
147
|
evaluate_script: {
|
|
@@ -154,6 +160,12 @@ export const commands = {
|
|
|
154
160
|
description: 'An optional list of arguments to pass to the function.',
|
|
155
161
|
required: false,
|
|
156
162
|
},
|
|
163
|
+
filePath: {
|
|
164
|
+
name: 'filePath',
|
|
165
|
+
type: 'string',
|
|
166
|
+
description: 'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
|
|
167
|
+
required: false,
|
|
168
|
+
},
|
|
157
169
|
dialogAction: {
|
|
158
170
|
name: 'dialogAction',
|
|
159
171
|
type: 'string',
|
|
@@ -234,8 +246,8 @@ export const commands = {
|
|
|
234
246
|
},
|
|
235
247
|
},
|
|
236
248
|
},
|
|
237
|
-
|
|
238
|
-
description: 'Loads a memory heapsnapshot and returns
|
|
249
|
+
get_heapsnapshot_class_nodes: {
|
|
250
|
+
description: 'Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --experimentalMemory=true)',
|
|
239
251
|
category: 'Memory',
|
|
240
252
|
args: {
|
|
241
253
|
filePath: {
|
|
@@ -244,46 +256,52 @@ export const commands = {
|
|
|
244
256
|
description: 'A path to a .heapsnapshot file to read.',
|
|
245
257
|
required: true,
|
|
246
258
|
},
|
|
259
|
+
id: {
|
|
260
|
+
name: 'id',
|
|
261
|
+
type: 'number',
|
|
262
|
+
description: 'The ID for the class, obtained from details.',
|
|
263
|
+
required: true,
|
|
264
|
+
},
|
|
247
265
|
pageIdx: {
|
|
248
266
|
name: 'pageIdx',
|
|
249
267
|
type: 'number',
|
|
250
|
-
description: 'The page index for pagination
|
|
268
|
+
description: 'The page index for pagination.',
|
|
251
269
|
required: false,
|
|
252
270
|
},
|
|
253
271
|
pageSize: {
|
|
254
272
|
name: 'pageSize',
|
|
255
273
|
type: 'number',
|
|
256
|
-
description: 'The page size for pagination
|
|
274
|
+
description: 'The page size for pagination.',
|
|
257
275
|
required: false,
|
|
258
276
|
},
|
|
259
277
|
},
|
|
260
278
|
},
|
|
261
|
-
|
|
262
|
-
description: '
|
|
263
|
-
category: '
|
|
279
|
+
get_heapsnapshot_details: {
|
|
280
|
+
description: 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --experimentalMemory=true)',
|
|
281
|
+
category: 'Memory',
|
|
264
282
|
args: {
|
|
265
|
-
|
|
266
|
-
name: '
|
|
267
|
-
type: 'number',
|
|
268
|
-
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
|
|
269
|
-
required: false,
|
|
270
|
-
},
|
|
271
|
-
requestFilePath: {
|
|
272
|
-
name: 'requestFilePath',
|
|
283
|
+
filePath: {
|
|
284
|
+
name: 'filePath',
|
|
273
285
|
type: 'string',
|
|
274
|
-
description: '
|
|
286
|
+
description: 'A path to a .heapsnapshot file to read.',
|
|
287
|
+
required: true,
|
|
288
|
+
},
|
|
289
|
+
pageIdx: {
|
|
290
|
+
name: 'pageIdx',
|
|
291
|
+
type: 'number',
|
|
292
|
+
description: 'The page index for pagination of aggregates.',
|
|
275
293
|
required: false,
|
|
276
294
|
},
|
|
277
|
-
|
|
278
|
-
name: '
|
|
279
|
-
type: '
|
|
280
|
-
description: 'The
|
|
295
|
+
pageSize: {
|
|
296
|
+
name: 'pageSize',
|
|
297
|
+
type: 'number',
|
|
298
|
+
description: 'The page size for pagination of aggregates.',
|
|
281
299
|
required: false,
|
|
282
300
|
},
|
|
283
301
|
},
|
|
284
302
|
},
|
|
285
|
-
|
|
286
|
-
description: 'Loads a memory heapsnapshot and returns
|
|
303
|
+
get_heapsnapshot_retainers: {
|
|
304
|
+
description: 'Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)',
|
|
287
305
|
category: 'Memory',
|
|
288
306
|
args: {
|
|
289
307
|
filePath: {
|
|
@@ -292,10 +310,10 @@ export const commands = {
|
|
|
292
310
|
description: 'A path to a .heapsnapshot file to read.',
|
|
293
311
|
required: true,
|
|
294
312
|
},
|
|
295
|
-
|
|
296
|
-
name: '
|
|
313
|
+
nodeId: {
|
|
314
|
+
name: 'nodeId',
|
|
297
315
|
type: 'number',
|
|
298
|
-
description: 'The
|
|
316
|
+
description: 'The node ID to get retainers for.',
|
|
299
317
|
required: true,
|
|
300
318
|
},
|
|
301
319
|
pageIdx: {
|
|
@@ -312,6 +330,42 @@ export const commands = {
|
|
|
312
330
|
},
|
|
313
331
|
},
|
|
314
332
|
},
|
|
333
|
+
get_heapsnapshot_summary: {
|
|
334
|
+
description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
|
|
335
|
+
category: 'Memory',
|
|
336
|
+
args: {
|
|
337
|
+
filePath: {
|
|
338
|
+
name: 'filePath',
|
|
339
|
+
type: 'string',
|
|
340
|
+
description: 'A path to a .heapsnapshot file to read.',
|
|
341
|
+
required: true,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
get_network_request: {
|
|
346
|
+
description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
|
|
347
|
+
category: 'Network',
|
|
348
|
+
args: {
|
|
349
|
+
reqid: {
|
|
350
|
+
name: 'reqid',
|
|
351
|
+
type: 'number',
|
|
352
|
+
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
|
|
353
|
+
required: false,
|
|
354
|
+
},
|
|
355
|
+
requestFilePath: {
|
|
356
|
+
name: 'requestFilePath',
|
|
357
|
+
type: 'string',
|
|
358
|
+
description: 'The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline.',
|
|
359
|
+
required: false,
|
|
360
|
+
},
|
|
361
|
+
responseFilePath: {
|
|
362
|
+
name: 'responseFilePath',
|
|
363
|
+
type: 'string',
|
|
364
|
+
description: 'The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.',
|
|
365
|
+
required: false,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
315
369
|
handle_dialog: {
|
|
316
370
|
description: 'If a browser dialog was opened, use this command to handle it',
|
|
317
371
|
category: 'Input automation',
|
|
@@ -471,18 +525,6 @@ export const commands = {
|
|
|
471
525
|
category: 'WebMCP',
|
|
472
526
|
args: {},
|
|
473
527
|
},
|
|
474
|
-
load_memory_snapshot: {
|
|
475
|
-
description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
|
|
476
|
-
category: 'Memory',
|
|
477
|
-
args: {
|
|
478
|
-
filePath: {
|
|
479
|
-
name: 'filePath',
|
|
480
|
-
type: 'string',
|
|
481
|
-
description: 'A path to a .heapsnapshot file to read.',
|
|
482
|
-
required: true,
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
},
|
|
486
528
|
navigate_page: {
|
|
487
529
|
description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
|
|
488
530
|
category: 'Navigation automation',
|
|
@@ -696,7 +738,7 @@ export const commands = {
|
|
|
696
738
|
},
|
|
697
739
|
},
|
|
698
740
|
},
|
|
699
|
-
|
|
741
|
+
take_heapsnapshot: {
|
|
700
742
|
description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
|
|
701
743
|
category: 'Memory',
|
|
702
744
|
args: {
|
|
@@ -136,33 +136,27 @@ export const cliOptions = {
|
|
|
136
136
|
},
|
|
137
137
|
experimentalPageIdRouting: {
|
|
138
138
|
type: 'boolean',
|
|
139
|
-
describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID.',
|
|
140
|
-
hidden: true,
|
|
139
|
+
describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions).',
|
|
141
140
|
},
|
|
142
141
|
experimentalDevtools: {
|
|
143
142
|
type: 'boolean',
|
|
144
143
|
describe: 'Whether to enable automation over DevTools targets',
|
|
145
|
-
hidden: true,
|
|
146
144
|
},
|
|
147
145
|
experimentalVision: {
|
|
148
146
|
type: 'boolean',
|
|
149
147
|
describe: 'Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots.',
|
|
150
|
-
hidden: false,
|
|
151
148
|
},
|
|
152
149
|
experimentalMemory: {
|
|
153
150
|
type: 'boolean',
|
|
154
151
|
describe: 'Whether to enable experimental memory tools.',
|
|
155
|
-
hidden: true,
|
|
156
152
|
},
|
|
157
153
|
experimentalStructuredContent: {
|
|
158
154
|
type: 'boolean',
|
|
159
155
|
describe: 'Whether to output structured formatted content.',
|
|
160
|
-
hidden: true,
|
|
161
156
|
},
|
|
162
157
|
experimentalIncludeAllPages: {
|
|
163
158
|
type: 'boolean',
|
|
164
159
|
describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
|
|
165
|
-
hidden: true,
|
|
166
160
|
},
|
|
167
161
|
experimentalNavigationAllowlist: {
|
|
168
162
|
type: 'boolean',
|
|
@@ -257,7 +251,7 @@ export const cliOptions = {
|
|
|
257
251
|
},
|
|
258
252
|
redactNetworkHeaders: {
|
|
259
253
|
type: 'boolean',
|
|
260
|
-
describe: 'If true, redacts some of the network headers considered
|
|
254
|
+
describe: 'If true, redacts some of the network headers considered sensitive before returning to the client.',
|
|
261
255
|
default: false,
|
|
262
256
|
},
|
|
263
257
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import '../polyfill.js';
|
|
7
7
|
import process from 'node:process';
|
|
8
|
+
import { closeBrowser } from '../browser.js';
|
|
8
9
|
import { createMcpServer, logDisclaimers } from '../index.js';
|
|
9
10
|
import { logger, saveLogsToFile } from '../logger.js';
|
|
10
11
|
import { ClearcutLogger } from '../telemetry/ClearcutLogger.js';
|
|
@@ -21,6 +22,43 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
|
|
|
21
22
|
logger('Unhandled promise rejection', promise, reason);
|
|
22
23
|
});
|
|
23
24
|
}
|
|
25
|
+
// Shutdown on stdin EOF (stdio MCP convention — the client closes the
|
|
26
|
+
// transport to signal exit) and on standard termination signals. Without
|
|
27
|
+
// this, an active Chrome subprocess keeps the Node event loop ref'd after
|
|
28
|
+
// stdin closes and the server hangs until something else kills it.
|
|
29
|
+
let shuttingDown = false;
|
|
30
|
+
async function shutdown(reason) {
|
|
31
|
+
if (shuttingDown) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
shuttingDown = true;
|
|
35
|
+
logger(`Shutting down (${reason})`);
|
|
36
|
+
// Backstop in case browser teardown hangs (e.g. unresponsive Chrome,
|
|
37
|
+
// slow beforeunload handlers, many tabs). Exits 0 because we still
|
|
38
|
+
// honored the shutdown request; the log line preserves observability.
|
|
39
|
+
// Unref'd so it doesn't keep the loop alive on the clean path.
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
logger('Shutdown timeout exceeded, forcing exit');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}, 10000).unref();
|
|
44
|
+
await closeBrowser();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
process.stdin.on('end', () => {
|
|
48
|
+
void shutdown('stdin end');
|
|
49
|
+
});
|
|
50
|
+
process.stdin.on('close', () => {
|
|
51
|
+
void shutdown('stdin close');
|
|
52
|
+
});
|
|
53
|
+
process.on('SIGTERM', () => {
|
|
54
|
+
void shutdown('SIGTERM');
|
|
55
|
+
});
|
|
56
|
+
process.on('SIGINT', () => {
|
|
57
|
+
void shutdown('SIGINT');
|
|
58
|
+
});
|
|
59
|
+
process.on('SIGHUP', () => {
|
|
60
|
+
void shutdown('SIGHUP');
|
|
61
|
+
});
|
|
24
62
|
logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
|
|
25
63
|
const { server } = await createMcpServer(args, {
|
|
26
64
|
logFile,
|
package/build/src/browser.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import { logger } from './logger.js';
|
|
11
11
|
import { puppeteer } from './third_party/index.js';
|
|
12
12
|
let browser;
|
|
13
|
+
let browserMode;
|
|
13
14
|
function makeTargetFilter(enableExtensions = false) {
|
|
14
15
|
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
|
|
15
16
|
if (!enableExtensions) {
|
|
@@ -95,7 +96,12 @@ export async function ensureBrowserConnected(options) {
|
|
|
95
96
|
}
|
|
96
97
|
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
|
|
97
98
|
try {
|
|
98
|
-
browser
|
|
99
|
+
// Assign mode before browser so a concurrent closeBrowser() never sees
|
|
100
|
+
// `browser` set with `browserMode` still undefined (would fall through
|
|
101
|
+
// to the disconnect() path and orphan a launched Chrome).
|
|
102
|
+
const connected = await puppeteer.connect(connectOptions);
|
|
103
|
+
browserMode = 'connected';
|
|
104
|
+
browser = connected;
|
|
99
105
|
}
|
|
100
106
|
catch (err) {
|
|
101
107
|
throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
|
|
@@ -198,7 +204,35 @@ export async function ensureBrowserLaunched(options) {
|
|
|
198
204
|
if (browser?.connected) {
|
|
199
205
|
return browser;
|
|
200
206
|
}
|
|
201
|
-
browser
|
|
207
|
+
// Assign mode before browser; see the connect path above for rationale.
|
|
208
|
+
const launched = await launch(options);
|
|
209
|
+
browserMode = 'launched';
|
|
210
|
+
browser = launched;
|
|
202
211
|
return browser;
|
|
203
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Shutdown hook for the active browser. Closes a launched browser (so the
|
|
215
|
+
* Chrome subprocess is reaped) or disconnects from an attached browser (so
|
|
216
|
+
* the user's Chrome instance stays alive). No-op if no browser is active or
|
|
217
|
+
* the connection has already been dropped. Called from the server entrypoint
|
|
218
|
+
* on stdin EOF / SIGTERM / SIGINT.
|
|
219
|
+
*/
|
|
220
|
+
export async function closeBrowser() {
|
|
221
|
+
const b = browser;
|
|
222
|
+
const mode = browserMode;
|
|
223
|
+
browser = undefined;
|
|
224
|
+
browserMode = undefined;
|
|
225
|
+
if (!b || !b.connected) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (mode === 'launched') {
|
|
229
|
+
await b.close().catch(err => {
|
|
230
|
+
logger('Failed to close browser', err);
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await b.disconnect().catch(err => {
|
|
235
|
+
logger('Failed to disconnect from browser', err);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
204
238
|
//# sourceMappingURL=browser.js.map
|
|
@@ -115,13 +115,8 @@ export async function handleResponse(response, format) {
|
|
|
115
115
|
if (response.isError) {
|
|
116
116
|
return JSON.stringify(response.content);
|
|
117
117
|
}
|
|
118
|
-
if (format === 'json') {
|
|
119
|
-
if (response.structuredContent) {
|
|
120
|
-
return JSON.stringify(response.structuredContent);
|
|
121
|
-
}
|
|
122
|
-
// Fall-through to text for backward compatibility.
|
|
123
|
-
}
|
|
124
118
|
const chunks = [];
|
|
119
|
+
const images = [];
|
|
125
120
|
for (const content of response.content) {
|
|
126
121
|
if (content.type === 'text') {
|
|
127
122
|
chunks.push(content.text);
|
|
@@ -143,12 +138,23 @@ export async function handleResponse(response, format) {
|
|
|
143
138
|
const name = crypto.randomUUID();
|
|
144
139
|
const filepath = await getTempFilePath(`${name}${extension}`);
|
|
145
140
|
fs.writeFileSync(filepath, data);
|
|
141
|
+
images.push({ filePath: filepath, mimeType });
|
|
146
142
|
chunks.push(`Saved to ${filepath}.`);
|
|
147
143
|
}
|
|
148
144
|
else {
|
|
149
145
|
throw new Error('Not supported response content type');
|
|
150
146
|
}
|
|
151
147
|
}
|
|
148
|
+
if (format === 'json') {
|
|
149
|
+
if (response.structuredContent) {
|
|
150
|
+
const structuredContent = {
|
|
151
|
+
...response.structuredContent,
|
|
152
|
+
...(images.length ? { images } : {}),
|
|
153
|
+
};
|
|
154
|
+
return JSON.stringify(structuredContent);
|
|
155
|
+
}
|
|
156
|
+
// Fall-through to text for backward compatibility.
|
|
157
|
+
}
|
|
152
158
|
return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks);
|
|
153
159
|
}
|
|
154
160
|
//# sourceMappingURL=client.js.map
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Copyright 2026 Google LLC
|
|
5
5
|
* SPDX-License-Identifier: Apache-2.0
|
|
6
6
|
*/
|
|
7
|
-
import fs from 'node:fs';
|
|
7
|
+
import fs, { constants, openSync, writeSync, closeSync } from 'node:fs';
|
|
8
8
|
import { createServer } from 'node:net';
|
|
9
|
+
import os from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import process from 'node:process';
|
|
11
12
|
import { logger } from '../logger.js';
|
|
@@ -19,10 +20,66 @@ if (isDaemonRunning(sessionId)) {
|
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
const pidFilePath = getPidFilePath(sessionId);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
fs.
|
|
23
|
+
const pidDir = path.dirname(pidFilePath);
|
|
24
|
+
const currentUserUid = os.userInfo().uid;
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
27
|
+
if (os.platform() !== 'win32') {
|
|
28
|
+
// POSIX specific checks
|
|
29
|
+
try {
|
|
30
|
+
const stats = fs.statSync(pidDir);
|
|
31
|
+
// 1. Check Ownership: Ensure the directory is owned by the current user.
|
|
32
|
+
if (stats.uid !== currentUserUid) {
|
|
33
|
+
console.error(`[MCP Daemon] Critical error: PID directory ${pidDir} is not owned by the current user (Expected: ${currentUserUid}, Found: ${stats.uid}). Possible tampering.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
// 2. Check Permissions: Ensure the directory is not group or world-writable.
|
|
37
|
+
// Mode is a number, e.g., 0o700. We check if bits for group/world write are set.
|
|
38
|
+
const mode = stats.mode;
|
|
39
|
+
if (mode & constants.S_IWGRP || mode & constants.S_IWOTH) {
|
|
40
|
+
console.error(`[MCP Daemon] Critical error: PID directory ${pidDir} has insecure permissions (Mode: ${mode.toString(8)}). It should not be writable by group or others.`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (statErr) {
|
|
45
|
+
console.error(`[MCP Daemon] Critical error stating PID directory ${pidDir}:`, statErr);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error(`[MCP Daemon] Critical error creating/validating PID directory: ${pidDir}`, err);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
let fd = -1;
|
|
55
|
+
try {
|
|
56
|
+
// Open the file with flags to:
|
|
57
|
+
// - O_WRONLY: Write-only
|
|
58
|
+
// - O_CREAT: Create if it doesn't exist
|
|
59
|
+
// - O_TRUNC: Truncate to zero length if it exists
|
|
60
|
+
// - O_NOFOLLOW: DO NOT follow symlinks.
|
|
61
|
+
// - 0o600: Permissions: read/write for owner, no permissions for others.
|
|
62
|
+
fd = openSync(pidFilePath, constants.O_WRONLY |
|
|
63
|
+
constants.O_CREAT |
|
|
64
|
+
constants.O_TRUNC |
|
|
65
|
+
constants.O_NOFOLLOW, 0o600);
|
|
66
|
+
writeSync(fd, process.pid.toString());
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(`[MCP Daemon] Critical error writing PID file: ${pidFilePath}`, err);
|
|
70
|
+
// If openSync fails due to O_NOFOLLOW on a symlink, the error will be caught here.
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (fd !== -1) {
|
|
75
|
+
try {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error(`[MCP Daemon] Error closing PID file: ${pidFilePath}`, err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
26
83
|
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
|
|
27
84
|
const socketPath = getSocketPath(sessionId);
|
|
28
85
|
const startDate = new Date();
|
|
@@ -3,10 +3,22 @@
|
|
|
3
3
|
* Copyright 2026 Google LLC
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
|
+
import { DevTools } from '../third_party/index.js';
|
|
6
7
|
import { stableIdSymbol } from '../utils/id.js';
|
|
7
8
|
export function isNodeLike(item) {
|
|
8
9
|
return (typeof item === 'object' && item !== null && 'id' in item && 'name' in item);
|
|
9
10
|
}
|
|
11
|
+
export function isEdgeLike(item) {
|
|
12
|
+
return (typeof item === 'object' &&
|
|
13
|
+
item !== null &&
|
|
14
|
+
'name' in item &&
|
|
15
|
+
'node' in item &&
|
|
16
|
+
'type' in item &&
|
|
17
|
+
typeof item.node === 'object' &&
|
|
18
|
+
item.node !== null &&
|
|
19
|
+
'id' in item.node &&
|
|
20
|
+
'name' in item.node);
|
|
21
|
+
}
|
|
10
22
|
export class HeapSnapshotFormatter {
|
|
11
23
|
#aggregates;
|
|
12
24
|
constructor(aggregates) {
|
|
@@ -14,12 +26,21 @@ export class HeapSnapshotFormatter {
|
|
|
14
26
|
}
|
|
15
27
|
static formatNodes(items) {
|
|
16
28
|
const lines = [];
|
|
17
|
-
if (items.length > 0
|
|
18
|
-
|
|
29
|
+
if (items.length > 0) {
|
|
30
|
+
const firstItem = items[0];
|
|
31
|
+
if (isNodeLike(firstItem)) {
|
|
32
|
+
lines.push('nodeId,nodeName,type,distance,selfSize,retainedSize');
|
|
33
|
+
}
|
|
34
|
+
else if (isEdgeLike(firstItem)) {
|
|
35
|
+
lines.push('name,type,nodeId,nodeName');
|
|
36
|
+
}
|
|
19
37
|
}
|
|
20
38
|
for (const item of items) {
|
|
21
39
|
if (isNodeLike(item)) {
|
|
22
|
-
lines.push(`${item.id}
|
|
40
|
+
lines.push(`${item.id},${item.name},${item.type},${item.distance},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.selfSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.retainedSize)}`);
|
|
41
|
+
}
|
|
42
|
+
else if (isEdgeLike(item)) {
|
|
43
|
+
lines.push(`${item.name},${item.type},${item.node.id},${item.node.name}`);
|
|
23
44
|
}
|
|
24
45
|
}
|
|
25
46
|
return lines.join('\n');
|
|
@@ -30,21 +51,21 @@ export class HeapSnapshotFormatter {
|
|
|
30
51
|
toString() {
|
|
31
52
|
const sorted = this.#getSortedAggregates();
|
|
32
53
|
const lines = [];
|
|
33
|
-
lines.push('
|
|
54
|
+
lines.push('id,name,count,selfSize,maxRetainedSize');
|
|
34
55
|
for (const info of sorted) {
|
|
35
|
-
const
|
|
36
|
-
lines.push(`${
|
|
56
|
+
const id = info[stableIdSymbol] ?? '';
|
|
57
|
+
lines.push(`${id},${info.name},${info.count},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.self)},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet)}`);
|
|
37
58
|
}
|
|
38
59
|
return lines.join('\n');
|
|
39
60
|
}
|
|
40
61
|
toJSON() {
|
|
41
62
|
const sorted = this.#getSortedAggregates();
|
|
42
63
|
return sorted.map(info => ({
|
|
43
|
-
|
|
64
|
+
id: info[stableIdSymbol],
|
|
44
65
|
className: info.name,
|
|
45
66
|
count: info.count,
|
|
46
|
-
selfSize: info.self,
|
|
47
|
-
retainedSize: info.maxRet,
|
|
67
|
+
selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.self),
|
|
68
|
+
retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet),
|
|
48
69
|
}));
|
|
49
70
|
}
|
|
50
71
|
static sort(aggregates) {
|