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

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

白いカードに、青い円を赤・黄・緑のリングが囲むマルチカラーのChromeロゴと、太字のほぼ黒で書かれたChromeの文字
目次

はじめに

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

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 本だけです。

.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 つのプロファイルは衝突しません。

共有プロファイルとリポジトリごとのプロファイル上段: リポジトリごとの MCP サーバー 2 つが、1 つの chrome-profile ディレクトリを指している。先に起動した方はブラウザを立ち上げられるが、もう一方はそのディレクトリですでにブラウザが動いているため拒否される。下段: 同じ 2 つのサーバーが profiles/ 以下の別々のディレクトリを指し、どちらも起動できる。既定: --userDataDir なしブログのリポジトリMCP サーバーサッカーアプリのリポジトリMCP サーバー~/.cache/chrome-devtools-mcp/chrome-profile拒否されるすでにブラウザが起動中--userDataDir を渡した場合ブログのリポジトリMCP サーバーサッカーアプリのリポジトリMCP サーバー~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog~/.cache/chrome-devtools-mcp/profiles/agentic-football-cup起動できる
既定では 1 つのプロファイルディレクトリが全プロジェクトを受け持つため、ブラウザを要求した 2 番目のサーバーは拒否されます。リポジトリごとにディレクトリを指定すれば、それぞれが自分のロックを持てます。

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

.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

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

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

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

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

ロックの中身

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

ターミナルウィンドウ
入力
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 は両方の場所に保存される乱数のトークンで、接続の前後に照合されます。これによって、たどり着いたソケットがこのプロファイルのものだとプロセスが判断できます。

Chrome のプロファイルをロックする 3 つのシンボリックリンクプロファイルディレクトリに 3 つのシンボリックリンクがある。SingletonLock はホスト名と PID を指し、誰が保持しているかを示す。SingletonSocket は Unix ソケットのパスを指し、そのプロセスへの接続先を示す。SingletonCookie は乱数を指し、そのソケットがこのプロファイルのものであることを証明する。いずれも中身を持つファイルではない。プロファイルディレクトリSingletonLockmymac.local-78752保持しているホスト名と PIDSingletonSocket/var/folders/…/SingletonSocketそのプロセスへの接続先SingletonCookie15445160568116473017このプロファイルのソケットである証明3 つともシンボリックリンクで、情報はリンク先の文字列にある。ファイルの中身ではない。
起動中の Chrome は 3 つのリンク先をすべて読みます。ロックは誰がプロファイルを保持しているかを、ソケットはその相手への接続先を示し、Cookie はそのソケットが正しいものであることを証明します。

Chromium はこの設計を chrome/browser/process_singleton_posix.cc 冒頭の長いコメントで説明しています。

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

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

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

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

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

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

[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 向けの権限降格リスト以外には出てきません。上の表は実測ですが、その裏にある仕組みは実測できていません。

保持中のプロファイルに対する 2 回目の起動: 引き渡しと中止1 つの起動が 2 つの経路に分かれる。左は通常の起動で、SingletonSocket に接続してコマンドラインを引き渡し、IDS_USED_EXISTING_BROWSER を出力して終了コード 0 で終わる。右は --enable-automation を付けた起動で、ランデブー自体が起きず、SingletonLock を作ろうとしてファイルが既に存在するため失敗し、LOCK_ERROR と終了コード 21 で終わる。Puppeteer は常にこのフラグを渡す。他のブラウザが保持中のプロファイルへの 2 回目の起動通常の起動SingletonSocket に接続コマンドラインを引き渡すIDS_USED_EXISTING_BROWSER終了コード 0--enable-automation 付きランデブーが起きないCreate() SingletonLockFile exists (17)LOCK_ERROR、終了コード 21Puppeteer は起動のたびに --enable-automation を渡すため、MCP が起動する Chrome は常に右の経路を通る。
同じ 2 回の起動、同じプロファイルです。通常の組み合わせはソケット越しに出会い、2 つ目のプロセスは静かに終了します。自動化した組み合わせは出会えないため、2 つ目は作れないロックの前で中止します。

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

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

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

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 のログ行から手元のメッセージまでの 3 層3 つのメッセージが積み重なっている。最下層で Chrome が、プロファイルディレクトリの ProcessSingleton を作成できなかったと標準エラー出力に書く。puppeteer-core がその文字列に一致させ、ブラウザが既に起動していると述べて別の userDataDir を勧める独自のエラーを投げる。chrome-devtools-mcp がさらにそのエラーに一致させ、--isolated を勧める 3 つ目のエラーを投げる。事象は 3 層とも同一で、変わるのは勧められる対処法だけ。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.下の層の文字列に一致させ、対処法を書き換える下の層の文字列に一致させ、対処法を書き換える提示される対処法は層ごとに変わるが、起きている事象は一度も変わらない。
各層は 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 つのフラグは排他のオプションとして宣言されているので、これは両立ではなく二者択一です。

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

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

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

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

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

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

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

ターミナルウィンドウ
入力
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/Cache82 MB閲覧したページの HTTP キャッシュ
Default/Service Worker66 MBService Worker のキャッシュ
component_crx_cache36 MBChrome のコンポーネント
WasmTtsEngine22 MB同梱の音声合成エンジン
Default/Code Cache7.9 MBコンパイル済みスクリプトのキャッシュ
OnDeviceHeadSuggestModel7.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 セッションはいまも衝突します。

参考リンク

この記事をシェア