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.

The multicolour Chrome logo, a blue circle ringed by red, yellow and green, beside the word Chrome in bold near-black on a white card
On this page

Introduction

Two of my projects drive Chrome through 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.

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.

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

One shared profile directory against one per repositoryAbove: two MCP servers, one per repository, both pointing at a single chrome-profile directory. The first launches a browser; the second is refused because a browser is already running for that directory. Below: the same two servers each point at their own directory under profiles/, and both launch.Default: no --userDataDirblog repoMCP serverfootball repoMCP server~/.cache/chrome-devtools-mcp/chrome-profilerefusedbrowser already runningWith --userDataDirblog repoMCP serverfootball repoMCP server~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog~/.cache/chrome-devtools-mcp/profiles/agentic-football-cuplaunches
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:

.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
Terminal window
IN
git check-ignore -v .mcp.json
OUT
.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.

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:

Terminal window
IN
ls -l ~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog | grep Singleton
OUT
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.

The three symlinks that lock a Chrome profileA profile directory containing three symlinks. SingletonLock points at a hostname and process id, naming the owner. SingletonSocket points at a Unix socket path, saying where to reach that process. SingletonCookie points at a random number that proves the socket belongs to this profile. None of the three is a file with contents.profile directorySingletonLockmymac.local-78752hostname and pid of the ownerSingletonSocket/var/folders/…/SingletonSocketwhere to reach that processSingletonCookie15445160568116473017proves the socket is this profile’sAll three are symlinks. The data is the target string, not file contents.
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.

Why a second Chrome usually just opens a window

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

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

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 launchSecond launchSecond’s exit codeWhat the second printed
plainplain0IDS_USED_EXISTING_BROWSER
plain--enable-automation21ProcessSingleton failure
--enable-automationplain21ProcessSingleton failure
--enable-automation--enable-automation21ProcessSingleton failure

The failure is two lines:

[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.

Handoff against abort, for a second Chrome on a held profileOne launch forking into two paths. On the left, a plain launch connects to SingletonSocket, hands over its command line, prints IDS_USED_EXISTING_BROWSER and exits 0. On the right, a launch carrying --enable-automation never rendezvous at all, tries to create SingletonLock, fails because the file exists, and exits 21 with LOCK_ERROR. Puppeteer always passes that flag.Second Chrome launch, profile already heldplain launchconnect to SingletonSockethand over the command lineIDS_USED_EXISTING_BROWSERexit 0with --enable-automationno rendezvous happensCreate() SingletonLockFile exists (17)LOCK_ERROR, exit 21Puppeteer passes --enable-automation on every launch, so an MCP-driven Chrome is always on the right-hand path.
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:

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:

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},
);
}
Three layers between Chrome’s log line and the advice you readThree stacked messages. At the bottom, Chrome writes to stderr that it failed to create a ProcessSingleton for the profile directory. puppeteer-core matches that text and throws its own error saying the browser is already running and suggesting a different userDataDir. chrome-devtools-mcp matches that error in turn and throws a third one suggesting --isolated. The condition is identical in all three; only the recommended remedy changes.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, on stderrFailed to create a ProcessSingleton for your profile directory.Aborting now to avoid profile corruption.matches the text below, replaces the remedymatches the text below, replaces the remedyWhat you are told to do changes at every layer. What went wrong never does.
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:

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:

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:

Terminal window
IN
for d in oharu-tech-blog agentic-football-cup; do echo "$d -> $(readlink .../profiles/$d/SingletonLock)"; done
OUT
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:

DirectorySizeWhat it holds
Default/Cache82 MBHTTP cache for pages visited
Default/Service Worker66 MBService worker caches
component_crx_cache36 MBChrome components
WasmTtsEngine22 MBBundled speech engine
Default/Code Cache7.9 MBCompiled script cache
OnDeviceHeadSuggestModel7.7 MBOmnibox 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

Share this article