讓每個專案裡的 chrome-devtools MCP 各自用一份 Chrome 設定檔 — 並設置 gitignore 的 .mcp.json

chrome-devtools-mcp 讓所有專案共用同一份 Chrome 設定檔,兩個 repo 無法同時驅動 Chrome。這個問題只要在每個專案設置各自的 .mcp.json 搭配 --userDataDir 就能解決。

白色卡片上的彩色 Chrome 標誌,藍色圓心外圍環繞紅、黃、綠三色,旁邊是粗體近黑色的 Chrome 字樣
本頁目錄

引言

我有兩個專案透過 chrome-devtools-mcp 驅動 Chrome:這個部落格,以及一個處理足球資料的應用。當我開著部落格那邊的瀏覽器,並切到另一個 repo 要求抓一個頁面時,卻發生了錯誤:

The browser is already running for /Users/me/.cache/chrome-devtools-mcp/chrome-profile.
Use --isolated to run multiple browser instances.

這是因為 Chrome 規定每一個設定檔目錄只能有一個瀏覽器行程。而官方外掛給機器上每一個專案的都是同一個目錄。解決辦法是 在每個 repo 放一份 .mcp.json,並各自傳入自己的 --userDataDir。這個檔案需要被 gitignore 掉:因為絕對路徑只在一台電腦上成立,repo 卻是要跨電腦共用的。

現在兩個專案可以同時驅動 Chrome,各自的登入狀態也都保留著。

每個專案一份 .mcp.json,以及它指定的設定檔目錄

每個 repo 在根目錄宣告自己的 chrome-devtools 伺服器,兩個 repo 之間唯一不同的就是一條路徑。

.mcp.json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@1.9.0",
"--userDataDir=/Users/me/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog"
]
}
}
}

那條路徑的最後一段就是 repo 自己的目錄名稱,兩份設定檔因此不會互相衝突,也不需要任何人去維護一份「哪個專案用哪個目錄」的清單。

共用單一設定檔目錄與每個 repo 各自一份的對照上半部:兩個 MCP 伺服器分屬兩個 repo,都指向同一個 chrome-profile 目錄。先啟動的那個可以開出瀏覽器,另一個則因為該目錄已經有瀏覽器在執行而被拒絕。下半部:同樣兩個伺服器各自指向 profiles/ 底下不同的目錄,兩個都能啟動。預設:沒有 --userDataDir部落格 repoMCP 伺服器足球應用 repoMCP 伺服器~/.cache/chrome-devtools-mcp/chrome-profile被拒絕瀏覽器已經在執行加上 --userDataDir部落格 repoMCP 伺服器足球應用 repoMCP 伺服器~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog~/.cache/chrome-devtools-mcp/profiles/agentic-football-cup可以啟動
預設情況下一個設定檔目錄要服務所有專案,所以第二個要求開瀏覽器的伺服器會被拒絕。改成每個 repo 指定一個目錄,各自就有了自己的鎖。

這個檔案本身不會進版控,因為那條絕對路徑只在一台機器上成立:

.gitignore
# Per-project chrome-devtools MCP server — gives this repo its own Chrome
# profile so it does not fight another project for the shared profile lock.
# The --userDataDir is an absolute path on one machine, so it is not portable.
.mcp.json
終端機視窗
輸入
git check-ignore -v .mcp.json
輸出
.gitignore:36:.mcp.json .mcp.json

如果你以為這個檔案會像一般的組態檔那樣運作,那有兩件事會讓你感到意外。MCP 伺服器是在 session 開始時解析的,所以寫好檔案之後,需要到下一個 session 才會生效;另外,要把伺服器列進 enabledMcpjsonServers,它才不會每個 session 都跳一次授權詢問。

這裡使用 npx 是刻意的,即使這台電腦的慣例是使用 pnpm dlx。這是因為這是一份長時間存在的伺服器定義,不是 shell 指令,所以應該和官方外掛實際執行的內容保持一致。

三個符號連結、一次交接,以及拒絕交接的那個 flag

Chrome 強制一個設定檔目錄只有一個瀏覽器行程,靠的是放在該目錄裡的三個符號連結。

這個鎖是由什麼組成的

三個都是符號連結,資訊在連結目標的字串裡,不在檔案內容中,所以這裡沒有任何一個是你打得開來讀的檔案:

終端機視窗
輸入
ls -l ~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog | grep Singleton
輸出
lrwxr-xr-x@ 1 me staff 20 Sep 19 11:25 SingletonCookie -> 15445160568116473017
lrwxr-xr-x@ 1 me staff 19 Sep 19 11:25 SingletonLock -> mymac.local-78752
lrwxr-xr-x@ 1 me staff 89 Sep 19 11:25 SingletonSocket -> /var/folders/h2/…/com.google.Chrome.22RNK3/SingletonSocket

SingletonLock 標示的是目前持有這份設定檔的主機名稱與行程 ID。 SingletonSocket 指向一個放在私有暫存目錄裡的 Unix domain socket,這樣即使設定檔放在網路檔案系統上,socket 也不會跟著放上去。

SingletonCookie 是一個同時存在兩邊的亂數 token,連線前後都會進行比對。有了它,行程才能確認自己連上的 socket 屬於這份設定檔。

鎖住 Chrome 設定檔的三個符號連結一個設定檔目錄裡有三個符號連結。SingletonLock 指向主機名稱與行程 ID,說明是誰持有。SingletonSocket 指向 Unix socket 路徑,說明要從哪裡連到那個行程。SingletonCookie 指向一個亂數,證明該 socket 屬於這個設定檔。三者都不是有內容的檔案。設定檔目錄SingletonLockmymac.local-78752持有者的主機名稱與 PIDSingletonSocket/var/folders/…/SingletonSocket要連到那個行程的位置SingletonCookie15445160568116473017證明這個 socket 屬於本設定檔三個都是符號連結,資訊在連結目標的字串裡,不在檔案內容中。
正在啟動的 Chrome 會讀取這三個連結目標:鎖會說明誰持有這份設定檔,socket 說明要從哪裡連上對方,cookie 則證明這個 socket 是對的那一個。

Chromium 在 chrome/browser/process_singleton_posix.cc 開頭一段很長的註解裡說明了這個設計。

為什麼第二個 Chrome 通常只是開另一個視窗

對著另一個 Chrome 正在持有的設定檔啟動第二個 Chrome,會成功:

終端機視窗
輸入
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --user-data-dir=/tmp/p about:blank
輸出
既存のブラウザ セッションで開いています。

那一行是 IDS_USED_EXISTING_BROWSER,會以 Chrome 當下執行的語言顯示。那個行程沒有畫出任何視窗,就以結束碼 0 退出。

這是 ProcessSingleton::NotifyOtherProcessOrCreate 的 PROCESS_NOTIFIED 分支:新的行程會連上 SingletonSocket,把自己的命令列交給回應的瀏覽器,等到收到確認回覆就結束。你開兩次 Chrome 只得到一個視窗,而不是第二個瀏覽器,這正是這段程式碼正常運作的結果。

讓交接不再發生的 flag

只要 其中任何一次 啟動加上 --enable-automation,交接就不會發生。四種組合的條件都一樣:同一個設定檔目錄,第二次啟動在第一次的五秒後。結果如下:

第一次啟動第二次啟動第二次的結束碼第二次印出的內容
一般一般0IDS_USED_EXISTING_BROWSER
一般--enable-automation21ProcessSingleton 失敗
--enable-automation一般21ProcessSingleton 失敗
--enable-automation--enable-automation21ProcessSingleton 失敗

失敗時是兩行:

[ERROR:chrome/browser/process_singleton_posix.cc:347] Failed to create /tmp/p/SingletonLock: File exists (17)
[ERROR:chrome/app/chrome_main_delegate.cc:550] Failed to create a ProcessSingleton for your profile directory. This means that running multiple instances would start multiple browser processes rather than opening a new window in the existing process. Aborting now to avoid profile corruption.

Chrome 只有在尋找並空手而回之後,才會去 建立 這個鎖。NotifyOtherProcessWithTimeoutOrCreate 會在通知步驟回傳 PROCESS_NONE 時呼叫 Create()。而 Create() 撞上已存在的符號連結失敗時,結果就是 LOCK_ERROR 與結束碼 21。

在自動化模式下,兩個瀏覽器永遠找不到彼此,所以第二個會一路運行到建立鎖的那一步,但是那個鎖早就被佔走了。

關於這個 flag 究竟在 Chromium 的哪裡造成這個效果,我翻遍幾個最明顯的地方都找不到答案。process_singleton_posix.cc 完全沒有提到 automation。chrome_main_delegate.cc 除了一份 Windows 降權清單之外也沒有。上面那張表是實測後的結果,表背後的機制則不是。

第二次啟動時的交接與中止兩條路徑一次啟動分成兩條路徑。左邊是一般啟動,連上 SingletonSocket,把命令列交給既有的瀏覽器,印出 IDS_USED_EXISTING_BROWSER 後以結束碼 0 離開。右邊帶著 --enable-automation,完全沒有交會,直接嘗試建立 SingletonLock,因為檔案已存在而失敗,以 LOCK_ERROR 和結束碼 21 收場。Puppeteer 一律會傳入該 flag。對已被佔用的設定檔進行第二次 Chrome 啟動一般啟動連到 SingletonSocket把命令列交給對方IDS_USED_EXISTING_BROWSER結束碼 0加上 --enable-automation完全沒有交會Create() SingletonLockFile exists (17)LOCK_ERROR,結束碼 21Puppeteer 每次啟動都會傳入 --enable-automation,所以由 MCP 啟動的 Chrome 一律走右邊這條路。
同樣兩次啟動、同一份設定檔。一般的那組透過 socket 交會,第二個行程安靜地結束;自動化的那組從未交會,於是第二個在一個建不起來的鎖前中止。

Puppeteer 把 --enable-automation 放在每次啟動都會傳入的預設參數清單裡,而 chrome-devtools-mcp 是透過 Puppeteer 啟動的。這個伺服器開出來的瀏覽器,無一例外都落在表格裡會中止的那幾種組合。

錯誤訊息的三個層次

那句要你用 --isolated 的建議,其實是 Chrome 寫進自己標準錯誤輸出的那行字經過層層改寫之後的第三個版本。

Puppeteer 在啟動失敗後讀取瀏覽器最近的日誌,並比對訊息字串:

puppeteer-core, BrowserLauncher.launch
if (logs.includes('Failed to create a ProcessSingleton for your profile directory') || …) {
// …
throw new Error(
`The browser is already running for ${launchArgs.userDataDir}. ` +
'Use a different `userDataDir` or stop the running browser first.',
);
}

chrome-devtools-mcp 接住那個錯誤,接著比對 它的 字串:

chrome-devtools-mcp, src/browser.ts
if (userDataDir && error.message.includes('The browser is already running')) {
throw new Error(
`The browser is already running for ${userDataDir}. ` +
'Use --isolated to run multiple browser instances.',
{cause: error},
);
}
從 Chrome 的日誌到你看到的建議之間的三層三則訊息由下往上堆疊。最下層是 Chrome 往標準錯誤輸出寫的內容,說它無法為該設定檔目錄建立 ProcessSingleton。puppeteer-core 比對這段字串,丟出自己的錯誤,說瀏覽器已經在執行並建議換一個 userDataDir。chrome-devtools-mcp 再比對那則錯誤,丟出第三則並建議使用 --isolated。三層描述的狀況完全相同,改變的只有建議的做法。chrome-devtools-mcpThe browser is already running for <dir>.Use --isolated to run multiple browser instances.puppeteer-coreThe browser is already running for <dir>.Use a different userDataDir or stop the running browser first.Chrome 的標準錯誤輸出Failed to create a ProcessSingleton for your profile directory.Aborting now to avoid profile corruption.比對下層的字串,換掉建議的做法比對下層的字串,換掉建議的做法每一層給的建議都不一樣,但實際發生的事從頭到尾都一樣。
每一層都比對下一層產生的文字,而且各自針對自己的讀者改寫建議的做法。

於是這個建議之前已經經歷了兩次改寫。你想要的明明是第二份設定檔,得到的卻是叫你把設定檔丟掉。

為什麼正確的修法是第二份設定檔

手動清掉那個鎖

把正在執行的瀏覽器關掉,確實會釋放設定檔,但 這並不是一個永久方案。下一次兩個專案同時需要瀏覽器,衝突就回來了。整個結構根本沒有改變。

而且通常也沒有殘留物可刪。收到 SIGTERM 的 Chrome 會在結束時刪掉自己的 SingletonLock,這是關掉一個測試用的瀏覽器之後確認的。鎖活得比行程久,只有在強制砍掉行程時才會完全釋放;即使如此,只要連結目標裡的行程 ID 已經不存在,Chrome 也會自己把這個孤兒鎖刪掉。

—isolated,以及它丟掉的登入

--isolated 是錯誤訊息建議的做法,它確實能解掉衝突。它的說明本身就寫明了代價:

creates a temporary user-data-dir that is automatically cleaned up after the browser is closed

關掉瀏覽器就清乾淨。意思是 每個 session 都從全部登出的狀態開始。這個伺服器的價值就在於驅動真實瀏覽器去看真實頁面,所以每個 session 的前幾分鐘都變成了重新登入的時間。

這兩個 flag 本來就就是互斥的選項,只能二選一,不可能並用。

關掉官方外掛,以及換來的取捨

官方外掛沒辦法指向每個專案自己的設定檔,因為它根本沒有傳任何參數給伺服器:

chrome-devtools-mcp@claude-plugins-official, mcp.json
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["--prefix", "${PLUGIN_DATA}", "chrome-devtools-mcp@1.9.0"]
}
}
}

在 userDataDir 未定義的情況下,伺服器會退回到一條由家目錄組出來的路徑:

chrome-devtools-mcp, src/browser.ts
if (!isolated && !userDataDir) {
userDataDir = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', profileDirName);
}

在 stable 頻道上 profileDirName 就是 chrome-profile,所以 機器上每個專案都會解析到同一個目錄,衝突的成因就在這一行程式碼裡。

因此把外掛全域關掉是修法的一部分,不是可做可不做的整理。代價也很明確:沒有 .mcp.json 的 repo 從此完全沒有 Chrome 相關工具可用。工具名稱也會跟著變:它們會以 mcp__chrome-devtools__* 出現,而不是外掛的 mcp__plugin_chrome-devtools-mcp_chrome-devtools__*,所以任何針對舊前綴寫的權限規則都會悄悄失效。

這個修法到不了的地方:同一個 repo 的兩個 session

這件事是在測試這套配置時發現的。部落格的設定檔上原本就有一個瀏覽器在跑,我又在足球專案的設定檔上啟動了第二個,兩者在同一個時刻各自持有自己的鎖:

終端機視窗
輸入
for d in oharu-tech-blog agentic-football-cup; do echo "$d -> $(readlink .../profiles/$d/SingletonLock)"; done
輸出
oharu-tech-blog -> mymac.local-78752
agentic-football-cup -> mymac.local-4494

接著,第三個 session 裡的一次 Chrome 呼叫,跳出了一模一樣的錯誤。那個 session 在部落格的 repo 裡,持有瀏覽器的那個 session 也在同一個 repo:同一個 repo 先後啟動過兩個伺服器,中間隔了好幾個小時,而瀏覽器屬於比較舊的那一個。

--userDataDir 讓每個 repo 各有一份設定檔,但不會讓每個 session 各有一份。因此在同一個專案裡開兩個 session,就會撞上同一個鎖,和從前兩個專案互撞的情形完全一樣。

把同樣的修法再往下推一層,就會變成每個 session 一份設定檔,等於用衝突換來一份永遠累積不了登入的設定檔。那只是多繞幾步的 --isolated。這也正是應該以 repo 為單位的理由。

一份設定檔佔掉多少磁碟

這裡日常使用的設定檔大約落在數百 MB,而 其中大部分是 Chrome 依自己的配額管理的快取,不是隔離造成的。其中一份量測到的內容如下,合計 244 MB:

目錄大小內容
Default/Cache82 MB瀏覽過頁面的 HTTP 快取
Default/Service Worker66 MBService Worker 的快取
component_crx_cache36 MBChrome 的元件
WasmTtsEngine22 MB內建的語音合成引擎
Default/Code Cache7.9 MB編譯後的指令碼快取
OnDeviceHeadSuggestModel7.7 MB網址列的建議模型

上面那幾項瀏覽相關的快取會隨著使用增加,並依配額被淘汰。底下那些元件目錄才是第二份設定檔真正重複佔用的部分,因為 每一份設定檔都會把另一份已經下載過的元件再下載一次。

這個量不會固定在某個數字上。component_crx_cache 在一份設定檔量到 36 MB,另一份是 63 MB。WasmTtsEngine 則是 22 MB 對 45 MB。數字比較大的那一份,就是啟動次數比較多的那一份。與其當成常數,不如以每個專案數十 MB 來抓預算。

刪掉那兩個快取目錄,從其中一份設定檔回收了 162 MB。它的 cookie 與已儲存的登入都還在。一個專案用完之後整份設定檔刪掉也沒問題,失去的只有那份登入。

總結

  1. Chrome 規定一個 user-data-dir 只能有一個瀏覽器行程,並以標示持有者主機與行程的 SingletonLock 符號連結強制執行。
  2. 人不會碰到這個限制,因為第二次啟動會透過 SingletonSocket 把命令列交給第一個,然後自己結束。
  3. 任一側只要帶上 --enable-automation,這個交接就不會發生,而 Puppeteer 每次啟動都會傳入它,所以人手開的瀏覽器會合流,自動化開的瀏覽器卻會相撞。
  4. 官方外掛執行伺服器時不帶任何參數,於是所有專案共用同一個設定檔目錄。
  5. 每個專案一份傳入 --userDataDir 的 .mcp.json 可以解決,代價是登入變成每個專案各自一份,以及重複下載一整套 Chrome 元件。
  6. 它是以 repo 為單位的,所以同一個 repo 裡的兩個 session 仍然會相撞。

參考連結

分享這篇文章