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.

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.
{ "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.
The file itself never gets committed, because that absolute path is true on exactly one machine:
# 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.jsonINgit check-ignore -v .mcp.jsonOUT.gitignore:36:.mcp.json .mcp.jsonTwo 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:
INls -l ~/.cache/chrome-devtools-mcp/profiles/oharu-tech-blog | grep SingletonOUTlrwxr-xr-x@ 1 me staff 20 Sep 19 11:25 SingletonCookie -> 15445160568116473017lrwxr-xr-x@ 1 me staff 19 Sep 19 11:25 SingletonLock -> mymac.local-78752lrwxr-xr-x@ 1 me staff 89 Sep 19 11:25 SingletonSocket -> /var/folders/h2/…/com.google.Chrome.22RNK3/SingletonSocketSingletonLock 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.
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:
IN"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --user-data-dir=/tmp/p about:blankOUT既存のブラウザ セッションで開いています。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:
[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.
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:
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:
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}, );}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:
{ "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:
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:
INfor d in oharu-tech-blog agentic-football-cup; do echo "$d -> $(readlink .../profiles/$d/SingletonLock)"; doneOUToharu-tech-blog -> mymac.local-78752agentic-football-cup -> mymac.local-4494Then 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
- Chrome permits one browser process per
user-data-dir, enforced by aSingletonLocksymlink naming the host and process that owns it. - A person never meets that limit, because a second launch hands its command line to the first over
SingletonSocketand exits. --enable-automationremoves that handoff from either side, and Puppeteer passes it on every launch, so automated browsers collide where manual ones merge.- The official plugin runs the server with no arguments, so every project shares one profile directory.
- A per-project
.mcp.jsonpassing--userDataDirfixes that, at the cost of per-project logins and a duplicated set of Chrome component downloads. - 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 designchrome/app/chrome_main_delegate.cc, where AcquireProcessSingleton turns a LOCK_ERROR into exit code 21- chrome-devtools-mcp, including the full list of server options such as —userDataDir and —isolated
- Claude Code MCP documentation, covering .mcp.json and enabledMcpjsonServers


