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.
@@ -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
- const profileDirName = channel && channel !== 'stable'
422
- ? `chrome-profile-${channel}`
423
- : 'chrome-profile';
424
- let userDataDir = options.userDataDir;
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
- // IMPORTANT: Chrome extensions (especially MV3 content scripts and service workers)
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.14.1",
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",