chrome-devtools-mcp-for-extension 0.14.1 → 0.15.1
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/browser.js +84 -20
- package/build/src/profile-resolver.js +199 -0
- package/build/src/project-detector.js +66 -0
- package/package.json +1 -1
package/build/src/browser.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import puppeteer from 'puppeteer-core';
|
|
10
|
+
import { resolveUserDataDir } from './profile-resolver.js';
|
|
10
11
|
let browser;
|
|
11
12
|
const ignoredPrefixes = new Set([
|
|
12
13
|
'chrome://',
|
|
@@ -418,20 +419,37 @@ export async function launch(options) {
|
|
|
418
419
|
const { channel, executablePath, customDevTools, headless, isolated, loadExtension, loadExtensionsDir, loadSystemExtensions, chromeProfile, } = options;
|
|
419
420
|
// Reset development extension paths
|
|
420
421
|
developmentExtensionPaths = [];
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
:
|
|
424
|
-
|
|
422
|
+
// Resolve user data directory using new profile resolver (v0.15.0+)
|
|
423
|
+
const resolved = resolveUserDataDir({
|
|
424
|
+
cliUserDataDir: options.userDataDir,
|
|
425
|
+
env: process.env,
|
|
426
|
+
cwd: process.cwd(),
|
|
427
|
+
channel: channel || 'stable',
|
|
428
|
+
});
|
|
429
|
+
const userDataDir = resolved.path;
|
|
430
|
+
await fs.promises.mkdir(userDataDir, { recursive: true });
|
|
431
|
+
// Legacy profile warning (shown if legacy path exists)
|
|
432
|
+
try {
|
|
433
|
+
const legacy = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', 'chrome-profile');
|
|
434
|
+
if (fs.existsSync(legacy)) {
|
|
435
|
+
console.error(`⚠️ Legacy profile detected: ${legacy}\n` +
|
|
436
|
+
`ℹ️ New profile location: ${path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', 'profiles', 'project-default', 'stable')}\n` +
|
|
437
|
+
`💡 To continue using the legacy profile, set: MCP_USER_DATA_DIR=${legacy}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
// Profile resolution logs
|
|
444
|
+
console.error(`[profiles] Using: ${userDataDir}`);
|
|
445
|
+
console.error(` Reason: ${resolved.reason}`);
|
|
446
|
+
console.error(` Project: ${resolved.projectName} (${resolved.hash})`);
|
|
447
|
+
console.error(` Client: ${resolved.clientId}`);
|
|
448
|
+
if (resolved.reason === 'AUTO') {
|
|
449
|
+
console.error(` Root: ${process.cwd()}`);
|
|
450
|
+
}
|
|
425
451
|
let usingSystemProfile = false;
|
|
426
452
|
let profileDirectory = 'Default';
|
|
427
|
-
if (!userDataDir) {
|
|
428
|
-
// Use isolated profile (independent from system Chrome)
|
|
429
|
-
userDataDir = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', profileDirName);
|
|
430
|
-
await fs.promises.mkdir(userDataDir, {
|
|
431
|
-
recursive: true,
|
|
432
|
-
});
|
|
433
|
-
console.error(`📁 Using isolated profile: ${userDataDir}`);
|
|
434
|
-
}
|
|
435
453
|
const args = [
|
|
436
454
|
'--hide-crash-restore-bubble',
|
|
437
455
|
`--profile-directory=${profileDirectory}`,
|
|
@@ -529,15 +547,17 @@ export async function launch(options) {
|
|
|
529
547
|
console.error(` Headless: ${headless}`);
|
|
530
548
|
console.error(` Args: ${JSON.stringify(args, null, 2)}`);
|
|
531
549
|
console.error(` Ignored Default Args: ["--disable-extensions", "--enable-automation"]`);
|
|
550
|
+
// IMPORTANT: Chrome extensions (especially MV3 content scripts and service workers)
|
|
551
|
+
// DO NOT work in headless mode. Always use headless:false when loading extensions.
|
|
552
|
+
// Reference: https://groups.google.com/a/chromium.org/g/headless-dev/c/nEoeUkoNI0o/m/9KZ4Os46AQAJ
|
|
553
|
+
const effectiveHeadless = extensionPaths.length > 0 ? false : headless;
|
|
554
|
+
if (extensionPaths.length > 0 && headless) {
|
|
555
|
+
console.warn('⚠️ WARNING: Extensions require headful mode. Forcing headless:false');
|
|
556
|
+
}
|
|
557
|
+
let browser;
|
|
558
|
+
let finalUserDataDir = userDataDir;
|
|
532
559
|
try {
|
|
533
|
-
|
|
534
|
-
// DO NOT work in headless mode. Always use headless:false when loading extensions.
|
|
535
|
-
// Reference: https://groups.google.com/a/chromium.org/g/headless-dev/c/nEoeUkoNI0o/m/9KZ4Os46AQAJ
|
|
536
|
-
const effectiveHeadless = extensionPaths.length > 0 ? false : headless;
|
|
537
|
-
if (extensionPaths.length > 0 && headless) {
|
|
538
|
-
console.warn('⚠️ WARNING: Extensions require headful mode. Forcing headless:false');
|
|
539
|
-
}
|
|
540
|
-
const browser = await puppeteer.launch({
|
|
560
|
+
browser = await puppeteer.launch({
|
|
541
561
|
...connectOptions,
|
|
542
562
|
channel: puppeterChannel,
|
|
543
563
|
executablePath: effectiveExecutablePath,
|
|
@@ -548,6 +568,50 @@ export async function launch(options) {
|
|
|
548
568
|
args,
|
|
549
569
|
ignoreDefaultArgs: ['--disable-extensions', '--enable-automation'],
|
|
550
570
|
});
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
// Profile lock collision fallback (v0.15.1)
|
|
574
|
+
const errorMsg = String(e.message || '').toLowerCase();
|
|
575
|
+
const isProfileLocked = errorMsg.includes('in use') ||
|
|
576
|
+
errorMsg.includes('lock') ||
|
|
577
|
+
errorMsg.includes('another chrome') ||
|
|
578
|
+
errorMsg.includes('profile appears to be');
|
|
579
|
+
if (isProfileLocked) {
|
|
580
|
+
// Fallback to ephemeral session
|
|
581
|
+
const sessionId = `${process.pid}-${Date.now()}`;
|
|
582
|
+
const tempPath = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', 'sessions', sessionId, channel || 'stable');
|
|
583
|
+
await fs.promises.mkdir(tempPath, { recursive: true });
|
|
584
|
+
console.error(`⚠️ Profile locked: ${userDataDir}`);
|
|
585
|
+
console.error(`📁 Falling back to ephemeral session: ${tempPath}`);
|
|
586
|
+
console.error(`💡 To avoid this, set MCP_CLIENT_ID (e.g., "claude-code", "codex")`);
|
|
587
|
+
// Clean up on exit
|
|
588
|
+
process.on('exit', () => {
|
|
589
|
+
try {
|
|
590
|
+
fs.rmSync(tempPath, { recursive: true, force: true });
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* ignore */
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
finalUserDataDir = tempPath;
|
|
597
|
+
// Retry with ephemeral profile
|
|
598
|
+
browser = await puppeteer.launch({
|
|
599
|
+
...connectOptions,
|
|
600
|
+
channel: puppeterChannel,
|
|
601
|
+
executablePath: effectiveExecutablePath,
|
|
602
|
+
defaultViewport: null,
|
|
603
|
+
userDataDir: tempPath,
|
|
604
|
+
pipe: true,
|
|
605
|
+
headless: effectiveHeadless,
|
|
606
|
+
args,
|
|
607
|
+
ignoreDefaultArgs: ['--disable-extensions', '--enable-automation'],
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
throw e;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
551
615
|
// Log actual spawn args for debugging
|
|
552
616
|
const spawnArgs = browser.process()?.spawnargs;
|
|
553
617
|
if (spawnArgs) {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/profile-resolver.ts
|
|
2
|
+
// Phase 1 (v0.15.0) + v0.15.1 (MCP_CLIENT_ID support)
|
|
3
|
+
// - Hybrid priority (CLI > MCP_USER_DATA_DIR > MCP_PROJECT_ID > AUTO > DEFAULT)
|
|
4
|
+
// - Auto-detection (git root -> nearest package.json -> cwd)
|
|
5
|
+
// - Realpath normalization
|
|
6
|
+
// - Tilde (~) expansion
|
|
7
|
+
// - Short SHA-256 hash (8 chars)
|
|
8
|
+
// - CI detection => ephemeral session profile (unless MCP_PERSIST_PROFILES)
|
|
9
|
+
// - Client ID isolation (MCP_CLIENT_ID environment variable)
|
|
10
|
+
// - Minimal console.error() logging of decision
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { detectProjectName, detectProjectRoot } from './project-detector.js';
|
|
16
|
+
const CACHE_ROOT = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp');
|
|
17
|
+
// --- Public API ---
|
|
18
|
+
export function resolveUserDataDir(opts) {
|
|
19
|
+
const channel = opts.channel || 'stable';
|
|
20
|
+
const clientId = sanitize(opts.env.MCP_CLIENT_ID || 'default');
|
|
21
|
+
// 0) CI detection → ephemeral session directory (unless MCP_PERSIST_PROFILES)
|
|
22
|
+
// - This happens before other priorities to keep CI clean by default.
|
|
23
|
+
if (isCI(opts.env) && !opts.env.MCP_PERSIST_PROFILES) {
|
|
24
|
+
const sessionId = `${process.pid}-${Date.now()}`;
|
|
25
|
+
const tempPath = realpathSafe(path.join(CACHE_ROOT, 'sessions', sessionId, channel));
|
|
26
|
+
// best-effort cleanup on exit
|
|
27
|
+
process.on('exit', () => {
|
|
28
|
+
try {
|
|
29
|
+
fs.rmSync(tempPath, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const result = {
|
|
36
|
+
path: tempPath,
|
|
37
|
+
reason: 'AUTO', // keep enum as specified (no EPHEMERAL type in Phase 1)
|
|
38
|
+
projectKey: `session-${sessionId}_${clientId}`,
|
|
39
|
+
projectName: 'ci-session',
|
|
40
|
+
hash: sessionId,
|
|
41
|
+
clientId,
|
|
42
|
+
channel,
|
|
43
|
+
};
|
|
44
|
+
// concise decision log (resolver-side)
|
|
45
|
+
console.error(`[profiles] resolved(AUTO, ci-ephemeral): ${result.path} (session=${sessionId}, client=${clientId})`);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
// 1) CLI explicit userDataDir
|
|
49
|
+
if (opts.cliUserDataDir && opts.cliUserDataDir.trim().length > 0) {
|
|
50
|
+
const p = realpathOrExpand(opts.cliUserDataDir);
|
|
51
|
+
const result = {
|
|
52
|
+
path: p,
|
|
53
|
+
reason: 'CLI',
|
|
54
|
+
projectKey: `${stripHomeForKey(p)}_${clientId}`,
|
|
55
|
+
projectName: 'cli',
|
|
56
|
+
hash: shortHash(p),
|
|
57
|
+
clientId,
|
|
58
|
+
channel,
|
|
59
|
+
};
|
|
60
|
+
console.error(`[profiles] resolved(CLI): ${result.path} (client=${clientId})`);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
// 2) ENV: MCP_USER_DATA_DIR (full path)
|
|
64
|
+
const envUserData = opts.env.MCP_USER_DATA_DIR?.trim();
|
|
65
|
+
if (envUserData) {
|
|
66
|
+
const p = realpathOrExpand(envUserData);
|
|
67
|
+
const result = {
|
|
68
|
+
path: p,
|
|
69
|
+
reason: 'MCP_USER_DATA_DIR',
|
|
70
|
+
projectKey: `${stripHomeForKey(p)}_${clientId}`,
|
|
71
|
+
projectName: 'env',
|
|
72
|
+
hash: shortHash(p),
|
|
73
|
+
clientId,
|
|
74
|
+
channel,
|
|
75
|
+
};
|
|
76
|
+
console.error(`[profiles] resolved(MCP_USER_DATA_DIR): ${result.path} (client=${clientId})`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
// 3) ENV: MCP_PROJECT_ID (project-scoped persistent profile)
|
|
80
|
+
const projectId = sanitize(opts.env.MCP_PROJECT_ID || '');
|
|
81
|
+
if (projectId) {
|
|
82
|
+
const key = `${projectId}_${clientId}`;
|
|
83
|
+
const p = projectProfilePath(key, channel);
|
|
84
|
+
const result = {
|
|
85
|
+
path: p,
|
|
86
|
+
reason: 'MCP_PROJECT_ID',
|
|
87
|
+
projectKey: key,
|
|
88
|
+
projectName: projectId,
|
|
89
|
+
hash: shortHash(projectId),
|
|
90
|
+
clientId,
|
|
91
|
+
channel,
|
|
92
|
+
};
|
|
93
|
+
console.error(`[profiles] resolved(MCP_PROJECT_ID): ${result.path} (projectId=${projectId}, client=${clientId})`);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
// 4) AUTO: detect by root -> name -> hash
|
|
97
|
+
try {
|
|
98
|
+
const root = detectProjectRoot(opts.cwd);
|
|
99
|
+
const name = detectProjectName(root);
|
|
100
|
+
const realRoot = realpathSafe(root);
|
|
101
|
+
const hash = shortHash(realRoot);
|
|
102
|
+
const key = `${sanitize(name)}_${hash}_${clientId}`;
|
|
103
|
+
const p = projectProfilePath(key, channel);
|
|
104
|
+
const result = {
|
|
105
|
+
path: p,
|
|
106
|
+
reason: 'AUTO',
|
|
107
|
+
projectKey: key,
|
|
108
|
+
projectName: sanitize(name),
|
|
109
|
+
hash,
|
|
110
|
+
clientId,
|
|
111
|
+
channel,
|
|
112
|
+
};
|
|
113
|
+
console.error(`[profiles] resolved(AUTO): ${result.path} (root=${root}, name=${name}, hash=${hash}, client=${clientId})`);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
// 5) DEFAULT fallback
|
|
118
|
+
const key = `project-default_${clientId}`;
|
|
119
|
+
const p = projectProfilePath(key, channel);
|
|
120
|
+
const result = {
|
|
121
|
+
path: p,
|
|
122
|
+
reason: 'DEFAULT',
|
|
123
|
+
projectKey: key,
|
|
124
|
+
projectName: 'project-default',
|
|
125
|
+
hash: '00000000',
|
|
126
|
+
clientId,
|
|
127
|
+
channel,
|
|
128
|
+
};
|
|
129
|
+
console.error(`[profiles] resolved(DEFAULT): ${result.path} (reason=${e?.message || 'fallback'}, client=${clientId})`);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// --- Helpers ---
|
|
134
|
+
function isCI(env) {
|
|
135
|
+
// Common CI signals
|
|
136
|
+
return env.CI === 'true' || env.GITHUB_ACTIONS === 'true';
|
|
137
|
+
}
|
|
138
|
+
function projectProfilePath(projectKey, channel) {
|
|
139
|
+
const base = path.join(CACHE_ROOT, 'profiles', projectKey, channel);
|
|
140
|
+
return pathNormalize(base);
|
|
141
|
+
}
|
|
142
|
+
function shortHash(s) {
|
|
143
|
+
try {
|
|
144
|
+
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 8);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Extremely unlikely; fallback for robustness
|
|
148
|
+
return '00000000';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function sanitize(s) {
|
|
152
|
+
const base = (s || 'project').toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
|
153
|
+
// Avoid empty string after sanitize
|
|
154
|
+
return base.length ? base : 'project';
|
|
155
|
+
}
|
|
156
|
+
function expandTilde(p) {
|
|
157
|
+
if (!p)
|
|
158
|
+
return p;
|
|
159
|
+
if (p.startsWith('~')) {
|
|
160
|
+
return path.join(os.homedir(), p.slice(1));
|
|
161
|
+
}
|
|
162
|
+
return p;
|
|
163
|
+
}
|
|
164
|
+
function realpathSafe(p) {
|
|
165
|
+
try {
|
|
166
|
+
// Use native realpath if available for speed/behavior
|
|
167
|
+
// (Node 18+ has fs.realpathSync.native)
|
|
168
|
+
const rp = fs.realpathSync.native
|
|
169
|
+
? fs.realpathSync.native(p)
|
|
170
|
+
: fs.realpathSync(p);
|
|
171
|
+
return rp;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// The path may not exist yet; normalize current form
|
|
175
|
+
return pathNormalize(p);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function realpathOrExpand(p) {
|
|
179
|
+
const expanded = expandTilde(p);
|
|
180
|
+
return realpathSafe(expanded);
|
|
181
|
+
}
|
|
182
|
+
function pathNormalize(p) {
|
|
183
|
+
// Normalize but do not resolve symlinks here
|
|
184
|
+
// (realpathSafe is used where resolution is desired)
|
|
185
|
+
return path.normalize(p);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Best-effort readable key when user supplied an absolute path.
|
|
189
|
+
* We do not want slashes in the key, so hash + tail dir name.
|
|
190
|
+
*/
|
|
191
|
+
function stripHomeForKey(absPath) {
|
|
192
|
+
try {
|
|
193
|
+
const name = path.basename(absPath);
|
|
194
|
+
return `${sanitize(name)}_${shortHash(absPath)}`;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return `abs_${shortHash(absPath)}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/project-detector.ts
|
|
2
|
+
// Phase 1 (v0.15.0)
|
|
3
|
+
// - detectProjectRoot: git root → nearest package.json → cwd
|
|
4
|
+
// - detectProjectName: package.json "name" → dirname
|
|
5
|
+
// - Use spawnSync with 500ms timeout to avoid blocking long
|
|
6
|
+
// - Realpath normalization
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
export function detectProjectRoot(cwd) {
|
|
11
|
+
const realCwd = realpathSafe(cwd);
|
|
12
|
+
// 1) Try 'git rev-parse --show-toplevel' with 500ms timeout
|
|
13
|
+
const git = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
14
|
+
cwd: realCwd,
|
|
15
|
+
timeout: 500,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
if (git.status === 0 && git.stdout) {
|
|
21
|
+
const out = git.stdout.toString().trim();
|
|
22
|
+
if (out)
|
|
23
|
+
return realpathSafe(out);
|
|
24
|
+
}
|
|
25
|
+
// If git is not available or command failed/timed out → fall through
|
|
26
|
+
// 2) Walk up to nearest package.json
|
|
27
|
+
let cur = realCwd;
|
|
28
|
+
while (true) {
|
|
29
|
+
if (fs.existsSync(path.join(cur, 'package.json')))
|
|
30
|
+
return cur;
|
|
31
|
+
const next = path.dirname(cur);
|
|
32
|
+
if (next === cur)
|
|
33
|
+
break; // reached filesystem root
|
|
34
|
+
cur = next;
|
|
35
|
+
}
|
|
36
|
+
// 3) Fallback to cwd
|
|
37
|
+
return realCwd;
|
|
38
|
+
}
|
|
39
|
+
export function detectProjectName(root) {
|
|
40
|
+
const realRoot = realpathSafe(root);
|
|
41
|
+
const pj = path.join(realRoot, 'package.json');
|
|
42
|
+
if (fs.existsSync(pj)) {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
45
|
+
if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
|
|
46
|
+
return String(pkg.name).trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore parse errors and fall back to directory name
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return path.basename(realRoot);
|
|
54
|
+
}
|
|
55
|
+
// --- helpers ---
|
|
56
|
+
function realpathSafe(p) {
|
|
57
|
+
try {
|
|
58
|
+
const rp = fs.realpathSync.native
|
|
59
|
+
? fs.realpathSync.native(p)
|
|
60
|
+
: fs.realpathSync(p);
|
|
61
|
+
return rp;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return path.normalize(p);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-devtools-mcp-for-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./build/src/index.js",
|