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

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

- Source: https://oharu121.com/zh-tw/blog/chrome-devtools-mcp-per-project-chrome-profile-userdatadir/
- Published: 2026-09-20T10:22:07+09:00
- Tags: MCP, Claude Code, Chrome, 開發工具

---
## 引言

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

```text
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 之間唯一不同的就是一條路徑**。

```json title=".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 自己的目錄名稱，兩份設定檔因此不會互相衝突，也不需要任何人去維護一份「哪個專案用哪個目錄」的清單。

*Figure — ProfileIsolation: 預設情況下一個設定檔目錄要服務所有專案，所以第二個要求開瀏覽器的伺服器會被拒絕。改成每個 repo 指定一個目錄，各自就有了自己的鎖。*

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

```bash title=".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
```

```bash
git check-ignore -v .mcp.json
# .gitignore:36:.mcp.json	.mcp.json
```

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

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

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

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

### 這個鎖是由什麼組成的

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

```bash
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 屬於這份設定檔。

*Figure — LockAnatomy: 正在啟動的 Chrome 會讀取這三個連結目標：鎖會說明誰持有這份設定檔，socket 說明要從哪裡連上對方，cookie 則證明這個 socket 是對的那一個。*

Chromium 在 [`chrome/browser/process_singleton_posix.cc`](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/browser/process_singleton_posix.cc) 開頭一段很長的註解裡說明了這個設計。

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

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

```bash
"/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`，交接就不會發生。四種組合的條件都一樣：同一個設定檔目錄，第二次啟動在第一次的五秒後。結果如下：

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

失敗時是兩行：

```text
[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 降權清單之外也沒有。**上面那張表是實測後的結果，表背後的機制則不是。**

*Figure — HandoffVsAbort: 同樣兩次啟動、同一份設定檔。一般的那組透過 socket 交會，第二個行程安靜地結束；自動化的那組從未交會，於是第二個在一個建不起來的鎖前中止。*

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

### 錯誤訊息的三個層次

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

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

```ts title="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 接住那個錯誤，接著比對 *它的* 字串：

```ts title="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},
  );
}
```

*Figure — ErrorLayers: 每一層都比對下一層產生的文字，而且各自針對自己的讀者改寫建議的做法。*

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

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

### 手動清掉那個鎖

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

而且通常也沒有殘留物可刪。**收到 `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 本來就就是互斥的選項，只能二選一，不可能並用。

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

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

```json title="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` 未定義的情況下，伺服器會退回到一條由家目錄組出來的路徑：

```ts title="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

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

```bash
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/Cache` | 82 MB | 瀏覽過頁面的 HTTP 快取 |
| `Default/Service Worker` | 66 MB | Service Worker 的快取 |
| `component_crx_cache` | 36 MB | Chrome 的元件 |
| `WasmTtsEngine` | 22 MB | 內建的語音合成引擎 |
| `Default/Code Cache` | 7.9 MB | 編譯後的指令碼快取 |
| `OnDeviceHeadSuggestModel` | 7.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 仍然會相撞。

## 參考連結

- [`chrome/browser/process_singleton_posix.cc`，開頭註解說明了 SingletonLock、SingletonSocket 與 SingletonCookie 的設計](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/browser/process_singleton_posix.cc)
- [`chrome/app/chrome_main_delegate.cc`，AcquireProcessSingleton 在這裡把 LOCK_ERROR 轉成結束碼 21](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/app/chrome_main_delegate.cc)
- [chrome-devtools-mcp，含 --userDataDir 與 --isolated 等伺服器選項的完整清單](https://github.com/ChromeDevTools/chrome-devtools-mcp)
- [Claude Code 的 MCP 文件，說明 .mcp.json 與 enabledMcpjsonServers](https://code.claude.com/docs/en/mcp)
