chrome-devtools-mcp-for-extension 0.14.0 → 0.15.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.
@@ -44,6 +44,9 @@ export class McpContext {
44
44
  #dialog;
45
45
  #nextSnapshotId = 1;
46
46
  #traceResults = [];
47
+ // CDP Session management (v0.8.4+)
48
+ #browserSession;
49
+ #pageSessions = new Map();
47
50
  constructor(browser, logger, browserFactory, connectionOptions) {
48
51
  this.browser = browser;
49
52
  this.logger = logger;
@@ -77,29 +80,38 @@ export class McpContext {
77
80
  await context.#init();
78
81
  return context;
79
82
  }
83
+ /**
84
+ * Dispose all CDP sessions to prevent memory leaks
85
+ */
86
+ async #disposeSessions() {
87
+ await Promise.allSettled([
88
+ this.#browserSession?.detach(),
89
+ ...[...this.#pageSessions.values()].map(s => s.detach())
90
+ ]);
91
+ this.#browserSession = undefined;
92
+ this.#pageSessions.clear();
93
+ }
80
94
  /**
81
95
  * Reinitialize CDP protocol domains after reconnection
82
96
  * This ensures proper CDP event handling after browser restart
83
97
  */
84
98
  async reinitializeCDP() {
85
99
  try {
86
- // Get CDP client from the browser
87
- const client = await this.browser._client();
88
- if (!client) {
89
- this.logger('Warning: Unable to get CDP client for reinitialization');
90
- return;
91
- }
92
- // 1. Enable Target discovery and auto-attach
93
- // This is critical for multi-target scenarios (iframes, workers, etc.)
100
+ // 1. Dispose old sessions to prevent leaks
101
+ await this.#disposeSessions();
102
+ // 2. Create browser-level CDP session (public API, not _client())
103
+ this.#browserSession = await this.browser.target().createCDPSession();
104
+ this.logger('CDP: Browser-level session created');
105
+ // 3. Enable Target discovery and auto-attach
94
106
  try {
95
- await client.send('Target.setDiscoverTargets', { discover: true });
107
+ await this.#browserSession.send('Target.setDiscoverTargets', { discover: true });
96
108
  this.logger('CDP: Target discovery enabled');
97
109
  }
98
110
  catch (err) {
99
111
  this.logger('Warning: Failed to enable target discovery:', err);
100
112
  }
101
113
  try {
102
- await client.send('Target.setAutoAttach', {
114
+ await this.#browserSession.send('Target.setAutoAttach', {
103
115
  autoAttach: true,
104
116
  waitForDebuggerOnStart: false,
105
117
  flatten: true
@@ -109,24 +121,29 @@ export class McpContext {
109
121
  catch (err) {
110
122
  this.logger('Warning: Failed to configure auto-attach:', err);
111
123
  }
112
- // 2. Enable essential CDP domains for each page
113
- const pages = await this.browser.pages();
114
- for (const page of pages) {
115
- try {
116
- const pageClient = page._client();
117
- if (pageClient) {
118
- // Enable Network domain for request interception
119
- await pageClient.send('Network.enable');
120
- // Enable Runtime domain for console messages and exceptions
121
- await pageClient.send('Runtime.enable');
122
- // Enable Log domain for browser logs
123
- await pageClient.send('Log.enable');
124
- this.logger(`CDP domains enabled for page: ${page.url()}`);
124
+ // 4. Enable essential CDP domains for all targets
125
+ for (const target of this.browser.targets()) {
126
+ const type = target.type();
127
+ if (type === 'page') {
128
+ const page = await target.page();
129
+ if (!page)
130
+ continue;
131
+ try {
132
+ const session = await page.createCDPSession();
133
+ await Promise.allSettled([
134
+ session.send('Network.enable'),
135
+ session.send('Runtime.enable'),
136
+ session.send('Log.enable'),
137
+ ]);
138
+ this.#pageSessions.set(page, session);
139
+ this.logger(`CDP domains enabled for ${type}: ${page.url()}`);
140
+ }
141
+ catch (err) {
142
+ this.logger(`Warning: Failed to enable CDP domains for ${type} ${page.url()}:`, err);
125
143
  }
126
144
  }
127
- catch (err) {
128
- this.logger(`Warning: Failed to enable CDP domains for page ${page.url()}:`, err);
129
- }
145
+ // Service Worker / Worker support can be added here if needed
146
+ // if (type === 'service_worker' || type === 'worker') { ... }
130
147
  }
131
148
  this.logger('CDP protocol reinitialization completed');
132
149
  }
@@ -140,6 +157,8 @@ export class McpContext {
140
157
  * Update browser instance after reconnection
141
158
  */
142
159
  async updateBrowser(newBrowser) {
160
+ // Dispose old sessions first
161
+ await this.#disposeSessions();
143
162
  this.browser = newBrowser;
144
163
  this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
145
164
  // Reinitialize CDP protocol domains BEFORE collectors
@@ -11,6 +11,7 @@ const DEFAULT_OPTIONS = {
11
11
  maxReconnectAttempts: 3,
12
12
  initialRetryDelay: 1000, // 1 second
13
13
  maxRetryDelay: 10000, // 10 seconds
14
+ reconnectOverallTimeoutMs: 30000, // 30 seconds
14
15
  enableLogging: true,
15
16
  onReconnect: undefined,
16
17
  };
@@ -43,6 +44,13 @@ export class BrowserConnectionManager {
43
44
  reconnectSequenceInFlight = null;
44
45
  /** Current connection state */
45
46
  state = ConnectionState.CLOSED;
47
+ /** Disconnected event handler (arrow function to preserve 'this') */
48
+ onDisconnected = () => {
49
+ this.log('Browser disconnected');
50
+ this.setState(ConnectionState.RECONNECTING);
51
+ // Trigger immediate reconnection (single-flight prevents duplicates)
52
+ void this.triggerReconnect('event:disconnected');
53
+ };
46
54
  constructor(options = {}) {
47
55
  this.options = { ...DEFAULT_OPTIONS, ...options };
48
56
  }
@@ -53,15 +61,15 @@ export class BrowserConnectionManager {
53
61
  * @param factory - Factory function to create new browser instances on reconnection
54
62
  */
55
63
  setBrowser(browser, factory) {
64
+ // Remove old listener to prevent memory leak
65
+ if (this.browser) {
66
+ this.browser.off('disconnected', this.onDisconnected);
67
+ }
56
68
  this.browser = browser;
57
69
  this.browserFactory = factory;
58
70
  this.state = ConnectionState.CONNECTED;
59
71
  // Event-driven detection: hook into browser 'disconnected' event
60
- this.browser.on('disconnected', () => {
61
- this.log('Browser disconnected event fired');
62
- this.setState(ConnectionState.CLOSED);
63
- // Trigger immediate reconnection (will be handled by next operation)
64
- });
72
+ this.browser.on('disconnected', this.onDisconnected);
65
73
  this.log('Browser instance set, state: CONNECTED');
66
74
  }
67
75
  /**
@@ -80,71 +88,81 @@ export class BrowserConnectionManager {
80
88
  }
81
89
  }
82
90
  /**
83
- * Retry operation with exponential backoff and jitter
91
+ * Trigger reconnection sequence
84
92
  *
85
- * Implements single-flight pattern at the sequence level:
86
- * - Multiple concurrent operations share the same reconnection sequence
87
- * - Only one reconnection sequence runs at a time
88
- * - All waiting operations retry after the shared sequence completes
93
+ * Can be called from events (disconnected) or operations (executeWithRetry).
94
+ * Uses single-flight pattern to prevent duplicate reconnections.
89
95
  *
90
- * Adds ±20% randomness to retry delays to prevent thundering herd problem.
96
+ * @param reason - Reason for triggering reconnection
97
+ * @returns Promise that resolves when reconnection completes
98
+ */
99
+ async triggerReconnect(reason) {
100
+ // Single-flight: return existing sequence if already running
101
+ if (this.reconnectSequenceInFlight) {
102
+ this.log(`Reconnection already in progress (${reason}), waiting...`);
103
+ return this.reconnectSequenceInFlight;
104
+ }
105
+ this.log(`Triggering reconnection: ${reason}`);
106
+ // Overall timeout with AbortController
107
+ const abortController = new AbortController();
108
+ const overallTimeout = this.options.reconnectOverallTimeoutMs ?? 30000;
109
+ const timeoutId = setTimeout(() => {
110
+ abortController.abort();
111
+ this.log(`Reconnection overall timeout (${overallTimeout}ms) exceeded`);
112
+ }, overallTimeout);
113
+ // Start reconnection sequence
114
+ this.reconnectSequenceInFlight = this._runReconnectionSequence(abortController.signal)
115
+ .finally(() => {
116
+ clearTimeout(timeoutId);
117
+ this.reconnectSequenceInFlight = null;
118
+ });
119
+ return this.reconnectSequenceInFlight;
120
+ }
121
+ /**
122
+ * Retry operation with automatic reconnection
91
123
  *
92
124
  * @param operation - Operation to retry
93
125
  * @param operationName - Name of operation for logging
94
126
  * @returns Result of operation
95
127
  */
96
128
  async retryWithReconnect(operation, operationName) {
97
- // Single-flight pattern: if a reconnection sequence is already running,
98
- // wait for it to complete, then try the operation once
99
- if (this.reconnectSequenceInFlight) {
100
- this.log(`${operationName}: Waiting for ongoing reconnection sequence...`);
101
- try {
102
- await this.reconnectSequenceInFlight;
103
- this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
104
- return await operation();
105
- }
106
- catch (error) {
107
- // Reconnection sequence failed, propagate the error
108
- throw error;
109
- }
110
- }
111
- // Start a new reconnection sequence
112
- this.reconnectSequenceInFlight = this._runReconnectionSequence();
113
- try {
114
- await this.reconnectSequenceInFlight;
115
- this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
116
- return await operation();
117
- }
118
- catch (error) {
119
- throw error;
120
- }
121
- finally {
122
- // Clear the in-flight sequence
123
- this.reconnectSequenceInFlight = null;
124
- }
129
+ // Use triggerReconnect for unified reconnection logic
130
+ await this.triggerReconnect(`operation:${operationName}`);
131
+ this.log(`${operationName}: Reconnection completed, retrying operation...`);
132
+ return await operation();
125
133
  }
126
134
  /**
127
135
  * Run a full reconnection sequence with exponential backoff
128
136
  *
129
137
  * This is the actual retry loop that multiple operations can share.
130
138
  *
139
+ * @param signal - AbortSignal to cancel reconnection
131
140
  * @private
132
141
  */
133
- async _runReconnectionSequence() {
142
+ async _runReconnectionSequence(signal) {
134
143
  const maxAttempts = this.options.maxReconnectAttempts ?? 3;
135
144
  const initialDelay = this.options.initialRetryDelay ?? 1000;
136
145
  const maxDelay = this.options.maxRetryDelay ?? 10000;
146
+ const random = this.options.rng ?? Math.random;
137
147
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
148
+ // Check abort signal
149
+ if (signal?.aborted) {
150
+ throw new Error('Reconnection aborted: overall timeout exceeded');
151
+ }
138
152
  this.reconnectAttempts++;
139
153
  const attemptNum = attempt + 1;
140
154
  this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
141
155
  // Exponential backoff with max delay
142
156
  const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
143
157
  // Add jitter: ±20% randomness to prevent thundering herd
144
- const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
158
+ const jitter = baseDelay * 0.2 * (random() * 2 - 1);
145
159
  const delay = Math.max(0, baseDelay + jitter);
146
160
  this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
147
161
  await this.sleep(delay);
162
+ // Check abort signal after sleep
163
+ if (signal?.aborted) {
164
+ throw new Error('Reconnection aborted: overall timeout exceeded');
165
+ }
148
166
  try {
149
167
  await this.reconnectBrowser();
150
168
  this.log(`Reconnection successful`);
@@ -164,7 +182,7 @@ export class BrowserConnectionManager {
164
182
  * Check if error is a CDP connection error
165
183
  *
166
184
  * Uses type-safe error detection with instanceof checks and falls back
167
- * to string matching for compatibility.
185
+ * to string matching for compatibility. Also checks method hints.
168
186
  *
169
187
  * @param error - Error to check
170
188
  * @returns true if error is a CDP connection error
@@ -174,13 +192,17 @@ export class BrowserConnectionManager {
174
192
  if (error instanceof ProtocolError || error instanceof TimeoutError) {
175
193
  return true;
176
194
  }
177
- // Fallback: string matching for error messages
178
- const errorMessage = error?.message || '';
179
- return (errorMessage.includes('Target closed') ||
180
- errorMessage.includes('Protocol error') ||
181
- errorMessage.includes('Session closed') ||
182
- errorMessage.includes('Connection closed') ||
183
- errorMessage.includes('WebSocket is not open'));
195
+ const msg = String(error?.message ?? '').toLowerCase();
196
+ const method = error?.method?.toString?.().toLowerCase?.() ?? '';
197
+ // Message hints
198
+ if (/connection closed|session closed|target closed|websocket is not open/.test(msg)) {
199
+ return true;
200
+ }
201
+ // Method hints (CDP methods like 'Target.*')
202
+ if (/^target\./.test(method)) {
203
+ return true;
204
+ }
205
+ return false;
184
206
  }
185
207
  /**
186
208
  * Reconnect browser instance with single-flight pattern
@@ -231,10 +253,7 @@ export class BrowserConnectionManager {
231
253
  const newBrowser = await this.browserFactory();
232
254
  this.browser = newBrowser;
233
255
  // Re-attach disconnected event handler
234
- this.browser.on('disconnected', () => {
235
- this.log('Browser disconnected event fired');
236
- this.setState(ConnectionState.CLOSED);
237
- });
256
+ this.browser.on('disconnected', this.onDisconnected);
238
257
  this.setState(ConnectionState.CONNECTED);
239
258
  this.log('Browser reconnected successfully');
240
259
  // Notify callback if provided
@@ -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,36 @@ 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
+ if (resolved.reason === 'AUTO') {
448
+ console.error(` Root: ${process.cwd()}`);
449
+ }
425
450
  let usingSystemProfile = false;
426
451
  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
452
  const args = [
436
453
  '--hide-crash-restore-bubble',
437
454
  `--profile-directory=${profileDirectory}`,
@@ -0,0 +1,190 @@
1
+ // src/profile-resolver.ts
2
+ // Phase 1 (v0.15.0)
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
+ // - Minimal console.error() logging of decision
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { detectProjectName, detectProjectRoot } from './project-detector.js';
15
+ const CACHE_ROOT = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp');
16
+ // --- Public API ---
17
+ export function resolveUserDataDir(opts) {
18
+ const channel = opts.channel || 'stable';
19
+ // 0) CI detection → ephemeral session directory (unless MCP_PERSIST_PROFILES)
20
+ // - This happens before other priorities to keep CI clean by default.
21
+ if (isCI(opts.env) && !opts.env.MCP_PERSIST_PROFILES) {
22
+ const sessionId = `${process.pid}-${Date.now()}`;
23
+ const tempPath = realpathSafe(path.join(CACHE_ROOT, 'sessions', sessionId, channel));
24
+ // best-effort cleanup on exit
25
+ process.on('exit', () => {
26
+ try {
27
+ fs.rmSync(tempPath, { recursive: true, force: true });
28
+ }
29
+ catch {
30
+ /* ignore */
31
+ }
32
+ });
33
+ const result = {
34
+ path: tempPath,
35
+ reason: 'AUTO', // keep enum as specified (no EPHEMERAL type in Phase 1)
36
+ projectKey: `session-${sessionId}`,
37
+ projectName: 'ci-session',
38
+ hash: sessionId,
39
+ channel,
40
+ };
41
+ // concise decision log (resolver-side)
42
+ console.error(`[profiles] resolved(AUTO, ci-ephemeral): ${result.path} (session=${sessionId})`);
43
+ return result;
44
+ }
45
+ // 1) CLI explicit userDataDir
46
+ if (opts.cliUserDataDir && opts.cliUserDataDir.trim().length > 0) {
47
+ const p = realpathOrExpand(opts.cliUserDataDir);
48
+ const result = {
49
+ path: p,
50
+ reason: 'CLI',
51
+ projectKey: stripHomeForKey(p),
52
+ projectName: 'cli',
53
+ hash: shortHash(p),
54
+ channel,
55
+ };
56
+ console.error(`[profiles] resolved(CLI): ${result.path}`);
57
+ return result;
58
+ }
59
+ // 2) ENV: MCP_USER_DATA_DIR (full path)
60
+ const envUserData = opts.env.MCP_USER_DATA_DIR?.trim();
61
+ if (envUserData) {
62
+ const p = realpathOrExpand(envUserData);
63
+ const result = {
64
+ path: p,
65
+ reason: 'MCP_USER_DATA_DIR',
66
+ projectKey: stripHomeForKey(p),
67
+ projectName: 'env',
68
+ hash: shortHash(p),
69
+ channel,
70
+ };
71
+ console.error(`[profiles] resolved(MCP_USER_DATA_DIR): ${result.path}`);
72
+ return result;
73
+ }
74
+ // 3) ENV: MCP_PROJECT_ID (project-scoped persistent profile)
75
+ const projectId = sanitize(opts.env.MCP_PROJECT_ID || '');
76
+ if (projectId) {
77
+ const p = projectProfilePath(projectId, channel);
78
+ const result = {
79
+ path: p,
80
+ reason: 'MCP_PROJECT_ID',
81
+ projectKey: projectId,
82
+ projectName: projectId,
83
+ hash: shortHash(projectId),
84
+ channel,
85
+ };
86
+ console.error(`[profiles] resolved(MCP_PROJECT_ID): ${result.path} (projectId=${projectId})`);
87
+ return result;
88
+ }
89
+ // 4) AUTO: detect by root -> name -> hash
90
+ try {
91
+ const root = detectProjectRoot(opts.cwd);
92
+ const name = detectProjectName(root);
93
+ const realRoot = realpathSafe(root);
94
+ const hash = shortHash(realRoot);
95
+ const key = `${sanitize(name)}_${hash}`;
96
+ const p = projectProfilePath(key, channel);
97
+ const result = {
98
+ path: p,
99
+ reason: 'AUTO',
100
+ projectKey: key,
101
+ projectName: sanitize(name),
102
+ hash,
103
+ channel,
104
+ };
105
+ console.error(`[profiles] resolved(AUTO): ${result.path} (root=${root}, name=${name}, hash=${hash})`);
106
+ return result;
107
+ }
108
+ catch (e) {
109
+ // 5) DEFAULT fallback
110
+ const key = 'project-default';
111
+ const p = projectProfilePath(key, channel);
112
+ const result = {
113
+ path: p,
114
+ reason: 'DEFAULT',
115
+ projectKey: key,
116
+ projectName: key,
117
+ hash: '00000000',
118
+ channel,
119
+ };
120
+ console.error(`[profiles] resolved(DEFAULT): ${result.path} (reason=${e?.message || 'fallback'})`);
121
+ return result;
122
+ }
123
+ }
124
+ // --- Helpers ---
125
+ function isCI(env) {
126
+ // Common CI signals
127
+ return env.CI === 'true' || env.GITHUB_ACTIONS === 'true';
128
+ }
129
+ function projectProfilePath(projectKey, channel) {
130
+ const base = path.join(CACHE_ROOT, 'profiles', projectKey, channel);
131
+ return pathNormalize(base);
132
+ }
133
+ function shortHash(s) {
134
+ try {
135
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 8);
136
+ }
137
+ catch {
138
+ // Extremely unlikely; fallback for robustness
139
+ return '00000000';
140
+ }
141
+ }
142
+ function sanitize(s) {
143
+ const base = (s || 'project').toLowerCase().replace(/[^a-z0-9-_]/g, '-');
144
+ // Avoid empty string after sanitize
145
+ return base.length ? base : 'project';
146
+ }
147
+ function expandTilde(p) {
148
+ if (!p)
149
+ return p;
150
+ if (p.startsWith('~')) {
151
+ return path.join(os.homedir(), p.slice(1));
152
+ }
153
+ return p;
154
+ }
155
+ function realpathSafe(p) {
156
+ try {
157
+ // Use native realpath if available for speed/behavior
158
+ // (Node 18+ has fs.realpathSync.native)
159
+ const rp = fs.realpathSync.native
160
+ ? fs.realpathSync.native(p)
161
+ : fs.realpathSync(p);
162
+ return rp;
163
+ }
164
+ catch {
165
+ // The path may not exist yet; normalize current form
166
+ return pathNormalize(p);
167
+ }
168
+ }
169
+ function realpathOrExpand(p) {
170
+ const expanded = expandTilde(p);
171
+ return realpathSafe(expanded);
172
+ }
173
+ function pathNormalize(p) {
174
+ // Normalize but do not resolve symlinks here
175
+ // (realpathSafe is used where resolution is desired)
176
+ return path.normalize(p);
177
+ }
178
+ /**
179
+ * Best-effort readable key when user supplied an absolute path.
180
+ * We do not want slashes in the key, so hash + tail dir name.
181
+ */
182
+ function stripHomeForKey(absPath) {
183
+ try {
184
+ const name = path.basename(absPath);
185
+ return `${sanitize(name)}_${shortHash(absPath)}`;
186
+ }
187
+ catch {
188
+ return `abs_${shortHash(absPath)}`;
189
+ }
190
+ }
@@ -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.0",
3
+ "version": "0.15.0",
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",