# A Chrome profile per project for chrome-devtools MCP — one gitignored .mcp.json each

> chrome-devtools-mcp shares one Chrome profile across all projects, so two repos cannot both drive Chrome. A per-project .mcp.json with --userDataDir fixes it.

- Source: https://oharu121.com/blog/chrome-devtools-mcp-per-project-chrome-profile-userdatadir/
- Published: 2026-09-20T10:22:07+09:00
- Tags: MCP, Claude Code, Chrome, Developer Tooling

---
## Introduction

Two of my projects drive Chrome through [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp): this blog, and a football-data app. A browser was already open for the blog when I switched to the other repo and asked for a page, and what came back was not a screenshot.

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

Chrome allows one browser process per profile directory, and the official plugin hands every project on the machine the same directory. The fix is **a `.mcp.json` in each repo passing its own `--userDataDir`**, gitignored because that path is absolute and the repository is not.

Both projects now drive Chrome at the same time, each keeping its own logins.

## One .mcp.json per project, and the profile directory it names

Each repository declares its own `chrome-devtools` server at its root, and **the only thing that differs between two repos is one path**.

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

The last segment of that path is the repository's own directory name. That is what keeps two profiles apart without anybody maintaining a list of which project owns which directory.

*Figure — ProfileIsolation: One profile directory serves every project by default, so the second server to ask for a browser is refused. Naming a directory per repository gives each one its own lock.*

The file itself never gets committed, because that absolute path is true on exactly one machine:

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

Two things about this file surprise people who expect it to behave like ordinary configuration. **MCP servers are resolved when a session starts**, so writing the file changes nothing until the next one. And listing the server in `enabledMcpjsonServers` is what stops it asking for approval on every session.

`npx` is deliberate here, even on a machine whose rule is to reach for `pnpm dlx`. This is a long-lived server specification rather than a shell command, and it mirrors what the official plugin already runs.

## Three symlinks, a handoff, and the flag that refuses it

Chrome enforces one browser process per profile directory, and it does so with three symlinks sitting in that directory.

### What the lock is made of

All three are symlinks whose **information lives in the link target rather than in any file contents**, so nothing here is a file you can read:

```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` names the host and the process id currently holding the profile.** `SingletonSocket` points at a Unix domain socket in a private temporary directory, which keeps the socket off any network filesystem the profile might live on.

`SingletonCookie` is a random token stored in both places and checked before and after connecting, so a process can tell that the socket it reached belongs to this profile.

*Figure — LockAnatomy: A launching Chrome reads all three targets: the lock says who owns the profile, the socket says where to reach them, and the cookie proves the socket is the right one.*

Chromium documents the design in a long comment at the top of [`chrome/browser/process_singleton_posix.cc`](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/browser/process_singleton_posix.cc).

### Why a second Chrome usually just opens a window

Start a second Chrome on a profile another Chrome is already holding and **it succeeds**:

```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --user-data-dir=/tmp/p about:blank
# 既存のブラウザ セッションで開いています。
```

That line is `IDS_USED_EXISTING_BROWSER`, rendered in whichever language Chrome is running in. The process exited 0 without drawing anything.

This is the `PROCESS_NOTIFIED` branch of `ProcessSingleton::NotifyOtherProcessOrCreate`. The new process connects to `SingletonSocket`, hands its command line to the browser that answers, waits for an acknowledgement, and exits. Opening Chrome twice giving you a window rather than a second browser is this code path, working.

### The flag that opts out of the handoff

Add `--enable-automation` to **either** launch and the handoff stops happening. Four pairs, same profile directory each time, second launch five seconds after the first:

| First launch | Second launch | Second's exit code | What the second printed |
| --- | --- | --- | --- |
| plain | plain | 0 | `IDS_USED_EXISTING_BROWSER` |
| plain | `--enable-automation` | 21 | ProcessSingleton failure |
| `--enable-automation` | plain | 21 | ProcessSingleton failure |
| `--enable-automation` | `--enable-automation` | 21 | ProcessSingleton failure |

The failure is two lines:

```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 only tries to *create* the lock after the rendezvous has already come back empty. `NotifyOtherProcessWithTimeoutOrCreate` calls `Create()` when the notify step returns `PROCESS_NONE`, and a `Create()` that fails on an existing symlink is what produces `LOCK_ERROR` and exit code 21.

**Under automation the two browsers never find each other**, so the second one falls through to claiming a lock that is already taken.

Where the switch has that effect in Chromium is not visible in the obvious places. `process_singleton_posix.cc` never mentions automation, and neither does `chrome_main_delegate.cc` outside a Windows de-elevation list. **The table above is measured; the mechanism behind it is not.**

*Figure — HandoffVsAbort: Same two launches, same profile. The plain pair rendezvous over the socket and the second process exits quietly; the automated pair never meet, so the second one aborts on a lock it cannot create.*

Puppeteer puts `--enable-automation` in the default argument list it passes on every launch, and chrome-devtools-mcp launches through Puppeteer. **Every browser this server starts is on the aborting side of that table.**

### The three layers of the error message

The advice to use `--isolated` is the third rewriting of a string Chrome wrote to its own stderr.

Puppeteer reads the browser's recent logs after a failed launch and matches on the message text:

```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 catches that error and matches on *its* text in turn:

```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: Each layer matches on the text the layer below it produced, and each rewrites the remedy for its own audience.*

So the suggestion arrives two rewrites away from the condition that caused it, which is how you end up being told to throw the profile away when the thing you wanted was a second one.

## Why the repair is a second profile

### Clearing the lock by hand

Killing the running browser does free the profile, and **it frees it exactly once**. The next time both projects want a browser at the same time, the collision is back, because nothing about the arrangement changed.

There is usually nothing stale to delete either. **A Chrome that receives `SIGTERM` removes its own `SingletonLock` on the way out**, confirmed here by killing a browser started for the test. A lock outlives its process only after a hard kill, and even then Chrome unlinks an orphaned one once the process id in its target is gone.

### --isolated, and the login it throws away

`--isolated` is what the error recommends, and it does clear the collision. Its own description says what it costs:

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

Cleaned up when the browser closes means **every session begins logged out of everything**. For a server whose whole value is driving a real browser against real pages, that turns each session's first few minutes into signing back in.

The two flags are declared as conflicting options, so this is a choice between them and never a pair.

## Turning off the official plugin, and the trade it buys

The plugin cannot be pointed at a per-project profile, because it passes no arguments to the server at all:

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

With `userDataDir` undefined, the server falls back to a path built from the home directory:

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

`profileDirName` is `chrome-profile` on the stable channel, so **every project on the machine resolves to the same directory**, which is the collision in one line of code.

Disabling the plugin globally is therefore part of the fix rather than an optional tidy-up, and the cost of that is exact: a repository without a `.mcp.json` now has no Chrome tools whatsoever. Tool names change too. They arrive as `mcp__chrome-devtools__*` instead of the plugin's `mcp__plugin_chrome-devtools-mcp_chrome-devtools__*`, so any permission rule written against the old prefix quietly stops matching.

## Where the fix stops: two sessions in one repo

Testing the arrangement is what turned this up. With a browser live on the blog's profile, I started a second one on the football project's profile, and both held their own lock at the same moment:

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

Then a Chrome call from a third session failed with the original error. That session was in the blog repository, and so was the session holding the browser. Two servers had been started for that one repository hours apart, and the browser belonged to the older of them.

**`--userDataDir` gives each repository a profile. It does not give each session one.** Two windows open on the same project collide on the same lock, in exactly the way two projects used to.

Pushing the same fix one level down would mean a profile per session, which trades the collision for a profile nothing can accumulate a login in. That is `--isolated` with extra steps, and it is why the repository is the right unit to key on.

## What a profile costs on disk

A profile in regular use here sits in the low hundreds of megabytes, and **most of that is cache Chrome manages against its own quota** rather than anything the isolation created. One of the two, measured at 244 MB:

| Directory | Size | What it holds |
| --- | --- | --- |
| `Default/Cache` | 82 MB | HTTP cache for pages visited |
| `Default/Service Worker` | 66 MB | Service worker caches |
| `component_crx_cache` | 36 MB | Chrome components |
| `WasmTtsEngine` | 22 MB | Bundled speech engine |
| `Default/Code Cache` | 7.9 MB | Compiled script cache |
| `OnDeviceHeadSuggestModel` | 7.7 MB | Omnibox suggestion model |

The browsing caches at the top grow with use and are evicted against quota. The component directories under them are the part a second profile genuinely duplicates, because **each profile downloads its own copy of components the other already has**.

Those do not settle at a fixed figure. `component_crx_cache` measured 36 MB in one profile and 63 MB in the other, and `WasmTtsEngine` 22 MB against 45 MB, the larger numbers belonging to the profile that had been launched more often. Budget for tens of megabytes per project rather than a constant.

Deleting the two cache directories reclaimed 162 MB from one profile and left its cookies and saved logins in place. Deleting a whole profile is safe whenever a project is finished with, and the only thing lost is its logins.

## Summary

1. Chrome permits one browser process per `user-data-dir`, enforced by a `SingletonLock` symlink naming the host and process that owns it.
2. A person never meets that limit, because a second launch hands its command line to the first over `SingletonSocket` and exits.
3. `--enable-automation` removes that handoff from either side, and Puppeteer passes it on every launch, so automated browsers collide where manual ones merge.
4. The official plugin runs the server with no arguments, so every project shares one profile directory.
5. A per-project `.mcp.json` passing `--userDataDir` fixes that, at the cost of per-project logins and a duplicated set of Chrome component downloads.
6. It is keyed to the repository, so two sessions in one repository still collide.

## References

- [`chrome/browser/process_singleton_posix.cc`, whose header comment documents the SingletonLock, SingletonSocket and SingletonCookie design](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/browser/process_singleton_posix.cc)
- [`chrome/app/chrome_main_delegate.cc`, where AcquireProcessSingleton turns a LOCK_ERROR into exit code 21](https://chromium.googlesource.com/chromium/src/+/HEAD/chrome/app/chrome_main_delegate.cc)
- [chrome-devtools-mcp, including the full list of server options such as --userDataDir and --isolated](https://github.com/ChromeDevTools/chrome-devtools-mcp)
- [Claude Code MCP documentation, covering .mcp.json and enabledMcpjsonServers](https://code.claude.com/docs/en/mcp)
