chrome-devtools-mcp-for-extension 0.18.0 → 0.18.2

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.
@@ -0,0 +1,46 @@
1
+ // Copyright 2024 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ // Unsure why this lint is failing, given `lantern/metrics/SpeedIndex.test.ts` does the same
5
+ // and is fine. Maybe `*.test.*` files are excluded from this rule?
6
+ // eslint-disable-next-line rulesdir/es-modules-import
7
+ import * as TraceLoader from '../../../../testing/TraceLoader.js';
8
+ import * as Trace from '../../trace.js';
9
+ import * as Lantern from '../lantern.js';
10
+ function toLanternTrace(traceEvents) {
11
+ return {
12
+ traceEvents: traceEvents,
13
+ };
14
+ }
15
+ async function runTraceProcessor(context, trace) {
16
+ TraceLoader.TraceLoader.setTestTimeout(context);
17
+ const processor = Trace.Processor.TraceProcessor.createWithAllHandlers();
18
+ await processor.parse(trace.traceEvents, { isCPUProfile: false, isFreshRecording: true });
19
+ if (!processor.data) {
20
+ throw new Error('No data');
21
+ }
22
+ return processor.data;
23
+ }
24
+ async function getComputationDataFromFixture(context, { trace, settings, url }) {
25
+ settings = settings ?? {};
26
+ if (!settings.throttlingMethod) {
27
+ settings.throttlingMethod = 'simulate';
28
+ }
29
+ const data = await runTraceProcessor(context, trace);
30
+ const requests = Trace.LanternComputationData.createNetworkRequests(trace, data);
31
+ const networkAnalysis = Lantern.Core.NetworkAnalyzer.analyze(requests);
32
+ if (!networkAnalysis) {
33
+ throw new Error('no networkAnalysis');
34
+ }
35
+ const frameId = data.Meta.mainFrameId;
36
+ const navigationId = data.Meta.mainFrameNavigations[0].args.data?.navigationId;
37
+ if (!navigationId) {
38
+ throw new Error('no navigation id found');
39
+ }
40
+ return {
41
+ simulator: Lantern.Simulation.Simulator.createSimulator({ ...settings, networkAnalysis }),
42
+ graph: Trace.LanternComputationData.createGraph(requests, trace, data, url),
43
+ processedNavigation: Trace.LanternComputationData.createProcessedNavigation(data, frameId, navigationId),
44
+ };
45
+ }
46
+ export { getComputationDataFromFixture, runTraceProcessor as runTrace, toLanternTrace, };
@@ -0,0 +1,4 @@
1
+ // Copyright 2024 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+ export * from './MetricTestUtils.js';
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import puppeteer from 'puppeteer-core';
10
10
  import { resolveUserDataDir } from './profile-resolver.js';
11
+ import { isProjectRootInitialized, getProjectRoot } from './project-root-state.js';
11
12
  let browser;
12
13
  const ignoredPrefixes = new Set([
13
14
  'chrome://',
@@ -719,6 +720,26 @@ async function ensureBrowserLaunched(options) {
719
720
  return browser;
720
721
  }
721
722
  export async function resolveBrowser(options) {
723
+ // CRITICAL: Project root must be initialized before launching Chrome
724
+ // This ensures proper profile isolation for multi-project environments
725
+ if (!isProjectRootInitialized() && !options.browserUrl && !options.userDataDir) {
726
+ const errorMsg = [
727
+ '❌ CRITICAL ERROR: Project root not initialized',
728
+ '',
729
+ 'Chrome cannot be launched because the MCP client did not provide the project root directory.',
730
+ 'This is required for proper Chrome profile isolation when using multiple projects.',
731
+ '',
732
+ '📋 Required client behavior:',
733
+ '1. Client must call setProjectRoot() immediately after MCP server starts',
734
+ '2. Or set MCP_PROJECT_ROOT environment variable before launching MCP',
735
+ '3. Or use --project-root CLI flag',
736
+ '',
737
+ `Current state: projectRoot=${getProjectRoot() || 'undefined'}`,
738
+ `Process cwd: ${process.cwd()}`,
739
+ ].join('\n');
740
+ console.error(errorMsg);
741
+ throw new Error('Project root not initialized - cannot launch Chrome without profile isolation');
742
+ }
722
743
  const resolvedBrowser = options.browserUrl
723
744
  ? await ensureBrowserConnected(options.browserUrl)
724
745
  : await ensureBrowserLaunched(options);
@@ -23,3 +23,16 @@ export const CHATGPT_CONFIG = {
23
23
  */
24
24
  DEFAULT_MODEL: 'gpt-5-thinking',
25
25
  };
26
+ /**
27
+ * Gemini configuration
28
+ */
29
+ export const GEMINI_CONFIG = {
30
+ /**
31
+ * Default Gemini URL
32
+ */
33
+ DEFAULT_URL: 'https://gemini.google.com/',
34
+ /**
35
+ * Base URL for Gemini
36
+ */
37
+ BASE_URL: 'https://gemini.google.com/',
38
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ /**
7
+ * Graceful Shutdown Module
8
+ *
9
+ * Ensures clean shutdown of MCP server:
10
+ * - Closes Puppeteer browser gracefully
11
+ * - Kills orphaned Chrome processes
12
+ * - Handles SIGTERM, SIGINT, disconnect, uncaught exceptions
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import process from 'node:process';
16
+ /**
17
+ * Setup graceful shutdown handlers
18
+ */
19
+ export function setupGraceful(options) {
20
+ const { getBrowser, onBeforeExit, pidFile = process.env.MCP_BROWSER_PID_FILE, closeTimeoutMs = 3000, } = options;
21
+ let isShuttingDown = false;
22
+ /**
23
+ * Kill Chrome process using PID file
24
+ */
25
+ async function killFromPidFile() {
26
+ if (!pidFile)
27
+ return;
28
+ try {
29
+ const txt = await fs.readFile(pidFile, 'utf8').catch(() => '');
30
+ const pid = Number(txt.trim());
31
+ if (pid > 0) {
32
+ try {
33
+ // Check if process exists
34
+ process.kill(pid, 0);
35
+ // Process exists, kill it
36
+ process.kill(pid, 'SIGKILL');
37
+ console.error(`[graceful] Killed Chrome process: ${pid}`);
38
+ }
39
+ catch (err) {
40
+ // Process doesn't exist (already dead)
41
+ console.error(`[graceful] Chrome process ${pid} already dead`);
42
+ }
43
+ }
44
+ await fs.rm(pidFile, { force: true });
45
+ }
46
+ catch (err) {
47
+ console.error(`[graceful] Error killing Chrome from PID file:`, err);
48
+ }
49
+ }
50
+ /**
51
+ * Graceful exit handler
52
+ */
53
+ async function gracefulExit(signal) {
54
+ if (isShuttingDown) {
55
+ console.error(`[graceful] Already shutting down, ignoring ${signal}`);
56
+ return;
57
+ }
58
+ isShuttingDown = true;
59
+ console.error(`[graceful] Shutting down... (signal: ${signal || 'unknown'})`);
60
+ try {
61
+ // Step 1: onBeforeExit callback
62
+ if (onBeforeExit) {
63
+ try {
64
+ await onBeforeExit();
65
+ }
66
+ catch (err) {
67
+ console.error(`[graceful] onBeforeExit error:`, err);
68
+ }
69
+ }
70
+ // Step 2: Close browser gracefully (with timeout)
71
+ const browser = getBrowser?.();
72
+ if (browser) {
73
+ try {
74
+ console.error(`[graceful] Closing browser...`);
75
+ await Promise.race([
76
+ Promise.resolve(browser.close?.()),
77
+ new Promise((_, reject) => setTimeout(() => reject(new Error('browser.close() timeout')), closeTimeoutMs)),
78
+ ]);
79
+ console.error(`[graceful] Browser closed successfully`);
80
+ }
81
+ catch (err) {
82
+ console.error(`[graceful] Browser close error:`, err);
83
+ // Continue to PID file cleanup
84
+ }
85
+ }
86
+ // Step 3: Kill orphaned Chrome (if browser.close() failed)
87
+ await killFromPidFile();
88
+ }
89
+ catch (err) {
90
+ console.error(`[graceful] Error during shutdown:`, err);
91
+ }
92
+ console.error(`[graceful] Shutdown complete`);
93
+ process.exit(0);
94
+ }
95
+ // Register signal handlers
96
+ process.on('SIGTERM', () => gracefulExit('SIGTERM'));
97
+ process.on('SIGINT', () => gracefulExit('SIGINT'));
98
+ process.on('disconnect', () => gracefulExit('disconnect')); // Parent died
99
+ process.on('uncaughtException', async (err) => {
100
+ console.error('[graceful] Uncaught exception:', err);
101
+ await gracefulExit('uncaughtException');
102
+ });
103
+ process.on('unhandledRejection', async (err) => {
104
+ console.error('[graceful] Unhandled rejection:', err);
105
+ await gracefulExit('unhandledRejection');
106
+ });
107
+ return {
108
+ /**
109
+ * Announce Chrome PID to wrapper via PID file
110
+ */
111
+ async announceBrowserPid(pid) {
112
+ if (!pidFile || !pid) {
113
+ console.error(`[graceful] Cannot announce browser PID: pidFile=${pidFile}, pid=${pid}`);
114
+ return;
115
+ }
116
+ try {
117
+ await fs.writeFile(pidFile, String(pid), 'utf8');
118
+ console.error(`[graceful] Announced browser PID ${pid} to ${pidFile}`);
119
+ }
120
+ catch (err) {
121
+ console.error(`[graceful] Failed to write PID file:`, err);
122
+ }
123
+ },
124
+ };
125
+ }
@@ -9,38 +9,146 @@
9
9
  */
10
10
  export async function isLoginRequired(page) {
11
11
  const currentUrl = page.url();
12
- // Method 1: Check URL
12
+ console.error(`[login-helper] Checking login status for URL: ${currentUrl}`);
13
+ // Method 1: Check URL patterns
13
14
  if (currentUrl.includes('auth') ||
14
15
  currentUrl.includes('login') ||
15
16
  currentUrl.includes('signin')) {
17
+ console.error('[login-helper] ✅ Login required (URL contains auth/login/signin)');
16
18
  return true;
17
19
  }
18
- // Method 2: Check for login UI elements
19
- try {
20
- // ChatGPT login page has specific elements
21
- const loginButton = await page.$('button[data-testid="login-button"]');
22
- const signUpButton = await page.$('a[href*="signup"]');
23
- const authContainer = await page.$('[class*="auth"]');
24
- if (loginButton || signUpButton || authContainer) {
20
+ // Gemini specific check
21
+ if (currentUrl.includes('gemini.google.com')) {
22
+ try {
23
+ const geminiContent = await page.evaluate(() => {
24
+ const bodyText = document.body.innerText.toLowerCase();
25
+ // Check for common login page text
26
+ const hasLoginText = bodyText.includes('sign in') ||
27
+ bodyText.includes('login') ||
28
+ bodyText.includes('google account');
29
+ // Check for Gemini composer
30
+ const hasComposer = !!(document.querySelector('div[contenteditable="true"]') ||
31
+ document.querySelector('textarea'));
32
+ return { hasLoginText, hasComposer };
33
+ });
34
+ console.error(`[login-helper] Gemini check: ${JSON.stringify(geminiContent)}`);
35
+ if (geminiContent.hasComposer) {
36
+ console.error('[login-helper] ❌ Login NOT required (Gemini composer detected)');
37
+ return false;
38
+ }
39
+ console.error('[login-helper] ✅ Login required (Gemini composer missing)');
40
+ return true;
41
+ }
42
+ catch (error) {
43
+ console.error(`[login-helper] Error checking Gemini login: ${error}`);
25
44
  return true;
26
45
  }
27
46
  }
28
- catch {
29
- // Element check failed, continue to next method
30
- }
31
- // Method 3: Check for main content absence
47
+ // Method 2: Check page content for login indicators
32
48
  try {
33
- // If we can't find the main composer, likely not logged in
34
- const composer = await page.$('textarea, .ProseMirror[contenteditable="true"]');
35
- if (!composer) {
36
- // No composer found - might be login page
49
+ const pageContent = await page.evaluate(() => {
50
+ // Check for common login page text
51
+ const bodyText = document.body.innerText.toLowerCase();
52
+ // ChatGPT-specific login indicators
53
+ const hasLoginText = bodyText.includes('log in') ||
54
+ bodyText.includes('sign in') ||
55
+ bodyText.includes('welcome to chatgpt');
56
+ // Check for login buttons - NATIVE DOM selectors only
57
+ const hasLoginButton = !!(document.querySelector('[data-testid*="login"]') ||
58
+ document.querySelector('[class*="login-button"]') ||
59
+ // Check for text content in buttons
60
+ Array.from(document.querySelectorAll('button')).some(btn => btn.textContent?.toLowerCase().includes('log in') ||
61
+ btn.textContent?.toLowerCase().includes('sign in')));
62
+ // Check for ChatGPT-specific composer (more strict detection)
63
+ // Look for the actual textarea/contenteditable that ChatGPT uses
64
+ let hasComposer = false;
65
+ // Method 1: Check for ChatGPT's main textarea (with validation)
66
+ const mainTextarea = document.querySelector('#prompt-textarea');
67
+ if (mainTextarea && mainTextarea instanceof HTMLTextAreaElement) {
68
+ // Additional validation: login page has a fallback textarea with class "_fallbackTextarea_"
69
+ // Real composer should NOT be disabled and should be in a proper composer container
70
+ const isFallback = mainTextarea.className.includes('fallback');
71
+ const isDisabled = mainTextarea.disabled;
72
+ if (!isFallback && !isDisabled) {
73
+ console.error('[login-helper] Found valid #prompt-textarea (not fallback)');
74
+ hasComposer = true;
75
+ }
76
+ else {
77
+ console.error('[login-helper] Found #prompt-textarea but it appears to be a fallback/disabled textarea');
78
+ }
79
+ }
80
+ // Method 2: Check Shadow DOM for ChatGPT's composer
81
+ if (!hasComposer) {
82
+ const shadowHosts = Array.from(document.querySelectorAll('*'));
83
+ for (const host of shadowHosts) {
84
+ if (host.shadowRoot) {
85
+ const shadowTextarea = host.shadowRoot.querySelector('#prompt-textarea');
86
+ const shadowComposer = host.shadowRoot.querySelector('[data-testid="composer-textarea"]');
87
+ if (shadowTextarea || shadowComposer) {
88
+ console.error('[login-helper] Found composer in Shadow DOM');
89
+ hasComposer = true;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ }
95
+ // Method 3: Check for ChatGPT-specific ProseMirror editor (only if it's in main content area)
96
+ if (!hasComposer) {
97
+ const proseMirror = document.querySelector('.ProseMirror[contenteditable="true"]');
98
+ // Verify it's actually ChatGPT's main composer, not just any ProseMirror
99
+ if (proseMirror) {
100
+ const parent = proseMirror.closest('[class*="composer"], [class*="prompt"], [class*="input-area"]');
101
+ if (parent) {
102
+ console.error('[login-helper] Found ChatGPT ProseMirror composer');
103
+ hasComposer = true;
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ hasLoginText,
109
+ hasLoginButton,
110
+ hasComposer,
111
+ bodySnippet: bodyText.substring(0, 200)
112
+ };
113
+ });
114
+ console.error(`[login-helper] Page analysis: ${JSON.stringify(pageContent, null, 2)}`);
115
+ // Decision point 1: PRIORITY - Login button present = needs login (even if fallback composer exists)
116
+ if (pageContent.hasLoginButton) {
117
+ console.error('[login-helper] 🔍 Decision: Login button detected (highest priority)');
118
+ console.error('[login-helper] hasLoginButton:', pageContent.hasLoginButton);
119
+ console.error('[login-helper] hasComposer:', pageContent.hasComposer);
120
+ console.error('[login-helper] ✅ Login required (login button detected)');
121
+ return true;
122
+ }
123
+ // Decision point 2: Login text present + NO valid composer = needs login
124
+ if (pageContent.hasLoginText && !pageContent.hasComposer) {
125
+ console.error('[login-helper] 🔍 Decision: Login text detected + No valid composer');
126
+ console.error('[login-helper] hasLoginText:', pageContent.hasLoginText);
127
+ console.error('[login-helper] hasComposer:', pageContent.hasComposer);
128
+ console.error('[login-helper] ✅ Login required (login UI detected, no composer)');
37
129
  return true;
38
130
  }
131
+ // Decision point 3: Valid composer present = logged in
132
+ if (pageContent.hasComposer) {
133
+ console.error('[login-helper] 🔍 Decision: Valid composer found (user is logged in)');
134
+ console.error('[login-helper] hasComposer:', pageContent.hasComposer);
135
+ console.error('[login-helper] ❌ Login NOT required (composer detected)');
136
+ return false;
137
+ }
138
+ // Decision point 3: Ambiguous state - safe default is to assume login required
139
+ console.error('[login-helper] 🔍 Decision: Unclear state (no login UI, no composer)');
140
+ console.error('[login-helper] hasLoginText:', pageContent.hasLoginText);
141
+ console.error('[login-helper] hasLoginButton:', pageContent.hasLoginButton);
142
+ console.error('[login-helper] hasComposer:', pageContent.hasComposer);
143
+ console.error('[login-helper] bodySnippet:', pageContent.bodySnippet);
144
+ console.error('[login-helper] âš ī¸ Unclear state - assuming login required for safety');
145
+ return true;
39
146
  }
40
- catch {
41
- // Element check failed
147
+ catch (error) {
148
+ console.error(`[login-helper] Error during page content check: ${error}`);
149
+ // On error, assume login required for safety
150
+ return true;
42
151
  }
43
- return false;
44
152
  }
45
153
  /**
46
154
  * Wait for user to complete login
package/build/src/main.js CHANGED
@@ -3,6 +3,34 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ // Mock browser globals for chrome-devtools-frontend (Node.js environment)
7
+ // NOTE: chrome-devtools-frontend expects browser globals (location, self, localStorage)
8
+ // These are only needed for trace-processing modules, not for core MCP functionality
9
+ if (typeof globalThis.location === 'undefined') {
10
+ globalThis.location = {
11
+ search: '',
12
+ href: '',
13
+ protocol: 'file:',
14
+ host: '',
15
+ hostname: '',
16
+ port: '',
17
+ pathname: '',
18
+ hash: '',
19
+ };
20
+ }
21
+ if (typeof globalThis.self === 'undefined') {
22
+ globalThis.self = globalThis;
23
+ }
24
+ if (typeof globalThis.localStorage === 'undefined') {
25
+ globalThis.localStorage = {
26
+ getItem: () => null,
27
+ setItem: () => { },
28
+ removeItem: () => { },
29
+ clear: () => { },
30
+ key: () => null,
31
+ length: 0,
32
+ };
33
+ }
6
34
  import assert from 'node:assert';
7
35
  import fs from 'node:fs';
8
36
  import path from 'node:path';
@@ -17,6 +45,8 @@ import { McpResponse } from './McpResponse.js';
17
45
  import { Mutex } from './Mutex.js';
18
46
  import { resolveRoots } from './roots-manager.js';
19
47
  import { runStartupCheck } from './startup-check.js';
48
+ import { setProjectRoot } from './project-root-state.js';
49
+ import { setupGraceful } from './graceful.js';
20
50
  import * as bookmarkTools from './tools/bookmarks.js';
21
51
  import * as chatgptWebTools from './tools/chatgpt-web.js';
22
52
  import * as deepResearchChatGPTTools from './tools/deep_research_chatgpt.js';
@@ -24,6 +54,7 @@ import * as consoleTools from './tools/console.js';
24
54
  import * as diagnoseUiTools from './tools/diagnose-ui.js';
25
55
  import * as emulationTools from './tools/emulation.js';
26
56
  import * as extensionTools from './tools/extensions.js';
57
+ import * as geminiWebTools from './tools/gemini-web.js';
27
58
  import * as inputTools from './tools/input.js';
28
59
  import * as networkTools from './tools/network.js';
29
60
  import * as pagesTools from './tools/pages.js';
@@ -69,6 +100,17 @@ let context;
69
100
  let uiHealthCheckRun = false; // Track if UI health check has been run
70
101
  let cachedRootsInfo = null; // Cache roots info
71
102
  let initializationComplete = false; // Track if MCP initialization is complete
103
+ // Setup graceful shutdown
104
+ const graceful = setupGraceful({
105
+ getBrowser: () => context?.browser,
106
+ onBeforeExit: async () => {
107
+ logger('[graceful] Running pre-exit cleanup...');
108
+ // Close log file if exists
109
+ if (logFile) {
110
+ logFile.close();
111
+ }
112
+ },
113
+ });
72
114
  async function getContext() {
73
115
  // Wait for initialization to complete before resolving roots
74
116
  if (!initializationComplete) {
@@ -86,6 +128,17 @@ async function getContext() {
86
128
  autoCwd: process.cwd(),
87
129
  });
88
130
  }
131
+ // Initialize project root for profile isolation
132
+ // Priority: CLI flag > MCP_PROJECT_ROOT env > Roots protocol > cwd
133
+ const rootFromRoots = cachedRootsInfo?.rootsUris?.[0]
134
+ ? cachedRootsInfo.rootsUris[0].replace('file://', '')
135
+ : undefined;
136
+ const projectRootToSet = args.projectRoot ||
137
+ process.env.MCP_PROJECT_ROOT ||
138
+ rootFromRoots ||
139
+ process.cwd();
140
+ setProjectRoot(projectRootToSet);
141
+ logger(`[project-root] Initialized: ${projectRootToSet}`);
89
142
  const browserOptions = {
90
143
  browserUrl: args.browserUrl,
91
144
  headless: args.headless,
@@ -102,6 +155,11 @@ async function getContext() {
102
155
  rootsInfo: cachedRootsInfo, // Pass roots info to browser
103
156
  };
104
157
  const browser = await resolveBrowser(browserOptions);
158
+ // Announce browser PID for graceful shutdown
159
+ const browserPid = browser.process()?.pid;
160
+ if (browserPid) {
161
+ await graceful.announceBrowserPid(browserPid);
162
+ }
105
163
  // Browser factory function for reconnection
106
164
  const browserFactory = async () => {
107
165
  logger('Reconnecting browser...');
@@ -188,6 +246,7 @@ const tools = [
188
246
  ...Object.values(bookmarkTools),
189
247
  ...Object.values(chatgptWebTools),
190
248
  ...Object.values(deepResearchChatGPTTools),
249
+ ...Object.values(geminiWebTools),
191
250
  ...Object.values(consoleTools),
192
251
  ...Object.values(diagnoseUiTools),
193
252
  ...Object.values(emulationTools),
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
16
  import { detectProjectName, detectProjectRoot } from './project-detector.js';
17
17
  import { detectClientType } from './client-detector.js';
18
+ import { getProjectRoot } from './project-root-state.js';
18
19
  const CACHE_ROOT = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp');
19
20
  // --- Public API ---
20
21
  export function resolveUserDataDir(opts) {
@@ -47,7 +48,25 @@ export function resolveUserDataDir(opts) {
47
48
  clientId = sanitize(detected);
48
49
  console.error(`[profiles] Auto-detected client from parent process: ${clientId}`);
49
50
  }
50
- // 0) CI detection → ephemeral session directory (unless MCP_PERSIST_PROFILES)
51
+ // 0a) MCP_SHARED_PROFILE → shared profile across all projects
52
+ // - Uses a single profile for all projects to avoid re-login
53
+ if (opts.env.MCP_SHARED_PROFILE === 'true') {
54
+ const key = 'shared';
55
+ const p = projectProfilePath(key, clientId, channel);
56
+ const result = {
57
+ path: p,
58
+ reason: 'MCP_USER_DATA_DIR', // Treat as explicit user choice
59
+ projectKey: key,
60
+ projectName: 'shared',
61
+ hash: '00000000',
62
+ clientId,
63
+ channel,
64
+ };
65
+ console.error(`[profiles] resolved(MCP_SHARED_PROFILE): ${result.path} (client=${clientId})`);
66
+ console.error(`[profiles] â„šī¸ Using shared profile - login state persists across all projects`);
67
+ return result;
68
+ }
69
+ // 0b) CI detection → ephemeral session directory (unless MCP_PERSIST_PROFILES)
51
70
  // - This happens before other priorities to keep CI clean by default.
52
71
  if (isCI(opts.env) && !opts.env.MCP_PERSIST_PROFILES) {
53
72
  const sessionId = `${process.pid}-${Date.now()}`;
@@ -122,9 +141,65 @@ export function resolveUserDataDir(opts) {
122
141
  console.error(`[profiles] resolved(MCP_PROJECT_ID): ${result.path} (projectId=${projectId}, client=${clientId})`);
123
142
  return result;
124
143
  }
125
- // 4) AUTO: detect by root -> name -> hash
144
+ // 3.5) ENV: MCP_PROJECT_ROOT (MCP Roots protocol - actual project directory)
145
+ const projectRoot = opts.env.MCP_PROJECT_ROOT;
146
+ if (projectRoot) {
147
+ console.error(`[profiles] MCP_PROJECT_ROOT detected: "${projectRoot}"`);
148
+ try {
149
+ const name = detectProjectName(projectRoot);
150
+ console.error(`[profiles] MCP_PROJECT_ROOT name="${name}"`);
151
+ const realRoot = realpathSafe(projectRoot);
152
+ const hash = shortHash(realRoot);
153
+ const key = `${sanitize(name)}_${hash}`;
154
+ const p = projectProfilePath(key, clientId, channel);
155
+ const result = {
156
+ path: p,
157
+ reason: 'MCP_PROJECT_ROOT',
158
+ projectKey: key,
159
+ projectName: sanitize(name),
160
+ hash,
161
+ clientId,
162
+ channel,
163
+ };
164
+ console.error(`[profiles] resolved(MCP_PROJECT_ROOT): ${result.path} (root=${projectRoot}, name=${name}, hash=${hash}, client=${clientId})`);
165
+ return result;
166
+ }
167
+ catch (e) {
168
+ console.error(`[profiles] MCP_PROJECT_ROOT resolution failed: ${e}`);
169
+ // Fall through to AUTO detection
170
+ }
171
+ }
172
+ // 4) INITIALIZED_PROJECT_ROOT: Use globally initialized project root (highest priority for AUTO mode)
173
+ const initializedRoot = getProjectRoot();
174
+ if (initializedRoot) {
175
+ console.error(`[profiles] Using initialized project root: "${initializedRoot}"`);
176
+ try {
177
+ const name = detectProjectName(initializedRoot);
178
+ console.error(`[profiles] Initialized root name="${name}"`);
179
+ const realRoot = realpathSafe(initializedRoot);
180
+ const hash = shortHash(realRoot);
181
+ const key = `${sanitize(name)}_${hash}`;
182
+ const p = projectProfilePath(key, clientId, channel);
183
+ const result = {
184
+ path: p,
185
+ reason: 'AUTO',
186
+ projectKey: key,
187
+ projectName: sanitize(name),
188
+ hash,
189
+ clientId,
190
+ channel,
191
+ };
192
+ console.error(`[profiles] resolved(INITIALIZED_ROOT): ${result.path} (root=${initializedRoot}, name=${name}, hash=${hash}, client=${clientId})`);
193
+ return result;
194
+ }
195
+ catch (e) {
196
+ console.error(`[profiles] Initialized root resolution failed: ${e}`);
197
+ // Fall through to cwd-based AUTO detection
198
+ }
199
+ }
200
+ // 5) AUTO: detect by root -> name -> hash (fallback if no initialized root)
126
201
  try {
127
- console.error(`[profiles] AUTO detection: cwd="${opts.cwd}"`);
202
+ console.error(`[profiles] AUTO detection (cwd fallback): cwd="${opts.cwd}"`);
128
203
  const root = detectProjectRoot(opts.cwd);
129
204
  console.error(`[profiles] AUTO detection: root="${root}"`);
130
205
  const name = detectProjectName(root);
@@ -0,0 +1,13 @@
1
+ // src/project-root-state.ts
2
+ // Global state for MCP project root (set during initialization)
3
+ let projectRoot;
4
+ export function setProjectRoot(path) {
5
+ projectRoot = path;
6
+ console.error(`[MCP] Project root initialized: ${path}`);
7
+ }
8
+ export function getProjectRoot() {
9
+ return projectRoot;
10
+ }
11
+ export function isProjectRootInitialized() {
12
+ return projectRoot !== undefined;
13
+ }