# chrome-devtools MCP にプロジェクトごとの Chrome プロファイルを持たせる — gitignore した .mcp.json を 1 つずつ

> chrome-devtools-mcp は全プロジェクトで 1 つの Chrome プロファイルを共有するため、2 つのリポジトリが同時に Chrome を操作できません。プロジェクトごとの .mcp.json と --userDataDir で解決します。

- Source: https://oharu121.com/ja/blog/chrome-devtools-mcp-per-project-chrome-profile-userdatadir/
- Published: 2026-09-20T10:22:07+09:00
- Tags: MCP, Claude Code, Chrome, 開発ツール

---
## はじめに

私のプロジェクトのうち 2 つが [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) 経由で Chrome を操作しています。このブログと、サッカーのデータを扱うアプリです。ブログ用のブラウザを開いたままもう一方のリポジトリに移り、ページを取得させたところ、返ってきたのはスクリーンショットではありませんでした。

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

Chrome はプロファイルディレクトリ 1 つにつきブラウザプロセスを 1 つしか許しません。そして公式プラグインは、マシン上のすべてのプロジェクトに同じディレクトリを渡します。解決策は **各リポジトリに `.mcp.json` を置き、それぞれ自分の `--userDataDir` を渡すこと**です。そのパスは 1 台のマシンでしか通用しない絶対パスなので、このファイルは gitignore します。

いまは 2 つのプロジェクトが同時に Chrome を操作でき、それぞれのログイン状態も保たれます。

## プロジェクトごとの .mcp.json と、そこに書くプロファイルディレクトリ

各リポジトリはルートに自分の `chrome-devtools` サーバーを宣言します。**2 つのリポジトリで違うのはパス 1 本だけ**です。

```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"
      ]
    }
  }
}
```

そのパスの末尾はリポジトリ自身のディレクトリ名です。どのプロジェクトがどのディレクトリを使うかという一覧を誰かが管理しなくても、これだけで 2 つのプロファイルは衝突しません。

*Figure — ProfileIsolation: 既定では 1 つのプロファイルディレクトリが全プロジェクトを受け持つため、ブラウザを要求した 2 番目のサーバーは拒否されます。リポジトリごとにディレクトリを指定すれば、それぞれが自分のロックを持てます。*

このファイル自体はコミットしません。その絶対パスが正しいのは 1 台のマシンの上だけだからです。

```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
```

このファイルには、普通の設定ファイルと同じつもりでいると驚く点が 2 つあります。1 つは **MCP サーバーがセッションの開始時に解決される**ことで、ファイルを書いても次のセッションまで何も変わりません。もう 1 つは、`enabledMcpjsonServers` にサーバーを列挙しておけば、セッションのたびに承認を求められずに済むことです。

`pnpm dlx` を使うのがこのマシンの方針ですが、ここで `npx` を使っているのは意図的です。これはシェルコマンドではなく長く使い続けるサーバーの定義であり、公式プラグインが実際に実行しているものとも揃います。

## 3 つのシンボリックリンクと、引き渡しと、それを拒むフラグ

Chrome はプロファイルディレクトリ 1 つにつきブラウザプロセスを 1 つに制限します。その仕組みは、そのディレクトリに置かれた 3 つのシンボリックリンクです。

### ロックの中身

3 つともシンボリックリンクで、**情報はリンク先の文字列にあり、ファイルの中身にはありません**。つまりここに読めるファイルは 1 つもありません。

```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 ドメインソケットを指します。こうすることで、プロファイルがネットワークファイルシステム上にあってもソケットはそこに置かれずに済みます。

`SingletonCookie` は両方の場所に保存される乱数のトークンで、接続の前後に照合されます。これによって、たどり着いたソケットがこのプロファイルのものだとプロセスが判断できます。

*Figure — LockAnatomy: 起動中の Chrome は 3 つのリンク先をすべて読みます。ロックは誰がプロファイルを保持しているかを、ソケットはその相手への接続先を示し、Cookie はそのソケットが正しいものであることを証明します。*

Chromium はこの設計を [`chrome/browser/process_singleton_posix.cc`](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/browser/process_singleton_posix.cc) 冒頭の長いコメントで説明しています。

### 2 回目の Chrome が普通はウィンドウを開くだけで終わる理由

他の Chrome が保持しているプロファイルに対して 2 つ目の 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 を 2 回開いたときに 2 つ目のブラウザではなくウィンドウが 1 枚増えるのは、このコードが正しく動いているからです。

### 引き渡しをやめさせるフラグ

`--enable-automation` を **どちらか一方の** 起動に付けるだけで、引き渡しは起きなくなります。プロファイルディレクトリは毎回同じ、2 回目の起動は 1 回目の 5 秒後という条件で、4 通りを試しました。

| 1 回目の起動 | 2 回目の起動 | 2 回目の終了コード | 2 回目の出力 |
| --- | --- | --- | --- |
| 通常 | 通常 | 0 | `IDS_USED_EXISTING_BROWSER` |
| 通常 | `--enable-automation` | 21 | ProcessSingleton の失敗 |
| `--enable-automation` | 通常 | 21 | ProcessSingleton の失敗 |
| `--enable-automation` | `--enable-automation` | 21 | ProcessSingleton の失敗 |

失敗時の出力は 2 行です。

```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 です。

**自動化モードでは 2 つのブラウザが互いを見つけられません**。そのため 2 つ目は、すでに押さえられているロックを自分で確保しようとするところまで進んでしまいます。

このフラグが Chromium のどこでその効果を持つのかは、分かりやすい場所には見当たりませんでした。`process_singleton_posix.cc` には automation という語が一度も出てきません。`chrome_main_delegate.cc` にも、Windows 向けの権限降格リスト以外には出てきません。**上の表は実測ですが、その裏にある仕組みは実測できていません。**

*Figure — HandoffVsAbort: 同じ 2 回の起動、同じプロファイルです。通常の組み合わせはソケット越しに出会い、2 つ目のプロセスは静かに終了します。自動化した組み合わせは出会えないため、2 つ目は作れないロックの前で中止します。*

Puppeteer は起動のたびに渡す既定の引数リストに `--enable-automation` を入れており、chrome-devtools-mcp は Puppeteer を通して起動します。**このサーバーが起動するブラウザは、どれも上の表で中止に終わる側の行に当てはまります。**

### エラーメッセージの 3 つの層

`--isolated` を使えという助言は、Chrome が自分の標準エラー出力に書いた文字列に対する 3 度目の書き換えです。

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: 各層は 1 つ下の層が出した文字列に一致させ、それぞれが自分の読み手に向けて対処法を書き換えます。*

つまりこの提案は、読み手に届くころには原因となった状況から 2 度書き換えられています。だから、欲しかったのは 2 つ目のプロファイルなのに、プロファイルを捨てろと言われることになるのです。

## 修正が 2 つ目のプロファイルである理由

### ロックを手で消す

動いているブラウザを終了させればプロファイルは確かに解放されます。ただし **解放されるのはその 1 回だけ**です。次に両方のプロジェクトが同時にブラウザを必要とした時点で衝突は戻ってきます。構成自体は何も変わっていないからです。

そもそも消すべき残骸がないことがほとんどです。**`SIGTERM` を受け取った Chrome は、終了する際に自分の `SingletonLock` を削除します**。これはテスト用に起動したブラウザを終了させて確認しました。ロックがプロセスより長く残るのは、強制終了のあとだけです。その場合でも、リンク先が指すプロセス ID がすでに存在しなければ、Chrome は孤立したロックを自分で削除します。

### --isolated と、それが捨てるログイン

`--isolated` はエラーが勧めてくるフラグで、確かに衝突は解消します。その代償は説明文自体に書かれています。

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

ブラウザを閉じると片付けられるということは、**毎回のセッションがすべてログアウトした状態から始まる**ということです。本物のブラウザで実際のページを動かせることがすべてのサーバーでは、セッションの最初の数分がログインし直すだけの時間になってしまいます。

2 つのフラグは排他のオプションとして宣言されているので、これは両立ではなく二者択一です。

## 公式プラグインを無効にすることと、その引き換え

公式プラグインでは、プロジェクトごとのプロファイルを指定できません。サーバーに引数を一切渡していないからです。

```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);
}
```

`profileDirName` は stable チャンネルでは `chrome-profile` です。つまり **マシン上のすべてのプロジェクトが同じディレクトリに行き着きます**。衝突の正体はこの 1 行です。

したがってプラグインをグローバルに無効化するのは、やってもやらなくてもよい後片付けではなく、修正そのものの一部です。その代償もはっきりしています。`.mcp.json` のないリポジトリでは Chrome 系のツールが一切使えなくなります。ツール名も変わります。プラグインの `mcp__plugin_chrome-devtools-mcp_chrome-devtools__*` ではなく `mcp__chrome-devtools__*` として現れるため、古い接頭辞に対して書かれた権限ルールは静かに一致しなくなります。

## この修正が届かない範囲: 同じリポジトリの 2 セッション

これが分かったのは、構成をテストしたときでした。ブログのプロファイルでブラウザが動いている状態で、サッカーのプロジェクトのプロファイルでもう 1 つ起動したところ、同じ瞬間に両方がそれぞれのロックを保持していました。

```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
```

ところが 3 つ目のセッションからの Chrome 呼び出しが、最初と同じエラーで失敗しました。そのセッションはブログのリポジトリにあり、ブラウザを保持していたセッションも同じリポジトリにありました。その 1 つのリポジトリに対して数時間の間隔でサーバーが 2 つ起動しており、ブラウザは古いほうのものでした。

**`--userDataDir` はリポジトリごとにプロファイルを与えますが、セッションごとには与えません**。同じプロジェクトでセッションを 2 つ開けば、かつて 2 つのプロジェクトがそうだったのとまったく同じように、同じロックで衝突します。

同じ修正をもう一段下に適用すれば、セッションごとのプロファイルになります。しかしそれでは、衝突がなくなる代わりに、ログインを貯めておけないプロファイルを選ぶことになります。手間が増えただけの `--isolated` です。だからこそ、鍵にすべき単位はリポジトリなのです。

## プロファイルがディスクを占める量

ここで日常的に使っているプロファイルは数百 MB 台に収まっており、**その大半は Chrome が自分の割り当て容量の範囲で管理しているキャッシュ**で、プロファイルを分けたせいで増えたものではありません。2 つのうち一方を測ったときの内訳は次のとおりです。合計は 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 | オムニボックスの候補モデル |

上段の閲覧系キャッシュは使ううちに増え、割り当てに従って破棄されます。その下のコンポーネント系ディレクトリが、2 つ目のプロファイルが実際に重複して持つ部分です。**それぞれのプロファイルが、もう一方がすでに持っているコンポーネントを自分でダウンロードします。**

この量は一定の値には落ち着きません。`component_crx_cache` は一方のプロファイルで 36 MB、もう一方で 63 MB でした。`WasmTtsEngine` は 22 MB と 45 MB です。大きいほうは、より多く起動されたプロファイルのものでした。定数ではなく、プロジェクトあたり数十 MB と見込んでおくのが妥当です。

キャッシュのディレクトリ 2 つを削除したところ、一方のプロファイルから 162 MB を回収でき、Cookie と保存済みのログインはそのまま残りました。プロジェクトを使い終えたときにプロファイルごと削除しても問題はなく、失われるのはそのログインだけです。

## まとめ

1. Chrome は `user-data-dir` 1 つにつきブラウザプロセスを 1 つしか許さず、それを保持するホストとプロセスを示す `SingletonLock` のシンボリックリンクで強制します。
2. 人間がこの制限に出会わないのは、2 回目の起動が `SingletonSocket` 越しに 1 回目へコマンドラインを引き渡して終了するからです。
3. `--enable-automation` はその引き渡しをどちらの側からも取り除きます。Puppeteer は起動のたびにこれを渡すため、手動なら合流する場面で自動化されたブラウザは衝突します。
4. 公式プラグインは引数なしでサーバーを実行するため、すべてのプロジェクトが 1 つのプロファイルディレクトリを共有します。
5. プロジェクトごとの `.mcp.json` で `--userDataDir` を渡せば解決しますが、ログインがプロジェクトごとになり、Chrome のコンポーネントを重複してダウンロードする代償があります。
6. 鍵になる単位はリポジトリなので、同じリポジトリの 2 セッションはいまも衝突します。

## 参考リンク

- [`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)
