Skip to content

fix: launch shared MCP via conhost --headless (no closable window)#2155

Closed
cejor6 wants to merge 6 commits into
ChromeDevTools:mainfrom
cejor6:fix/headless-conhost-launcher
Closed

fix: launch shared MCP via conhost --headless (no closable window)#2155
cejor6 wants to merge 6 commits into
ChromeDevTools:mainfrom
cejor6:fix/headless-conhost-launcher

Conversation

@cejor6
Copy link
Copy Markdown

@cejor6 cejor6 commented May 29, 2026

Problem

The shared-MCP scheduled task (ChromeDevToolsMcpShared) launched the server with powershell.exe -WindowStyle Hidden. That powershell.exe is the parent of the node server process, so if any visible host window ever appears (e.g. a brief logon-time console flash on some boots), closing it kills the entire server tree. This actually happened — the server went down mid-session after a host window was closed.

Fix

Switch the scheduled-task action from:

powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File launcher.ps1

to:

conhost.exe --headless powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File launcher.ps1

conhost --headless allocates a pseudoconsole with no window at any point — no logon flash, nothing for the user to see or accidentally close. The powershell + node tree stays attached to the task, so the task remains Running and the existing -RestartCount/-RestartInterval restart-on-failure policy still applies (unlike the previously-rejected wscript fire-and-forget approach, which is noted in the updated comment).

Verification

Applied the same change to the live registered task and confirmed the new process tree:

node (PID 50868)        HasWindow=False
  powershell.exe        HasWindow=False
    conhost.exe         HasWindow=False   <- --headless host
      svchost.exe (Task Scheduler)
  • Server LISTENING on 127.0.0.1:9876, task state Running
  • HTTP probe to /mcp returns 400 (reachable; rejecting the empty probe body as expected)
  • No console window anywhere in the tree

🤖 Generated with Claude Code

cejor6 and others added 6 commits May 27, 2026 10:13
## Summary

Two changes that together make the fork usable by multiple agents
driving different pages on the same Chrome instance.

### Per-page mutex (`MutexRegistry`)
Replaces the single global tool mutex with one mutex per `pageId`. With
`--experimentalPageIdRouting`, two tools on two different pages run in
parallel. Topology tools (`new_page`, `close_page`, `select_page`,
`list_pages`) use a new `acquireExclusive()` that drains every per-page
mutex first, so page topology never mutates while page work is in
flight. Legacy single-flight behaviour preserved when the flag is off.

### Streamable HTTP transport
New `src/HttpTransport.ts` exposes an HTTP endpoint alongside stdio.
Independent client processes can now attach to the same browser.

- `--http-port <N>` — enable; stdio stays active.
- `--http-host <addr>` — bind host, default `127.0.0.1`. Non-loopback
requires `--http-token`.
- `--http-token <token>` — bearer auth via `Authorization: Bearer …`.

Each HTTP session gets its own `McpServer` but shares one
browser/context/mutex registry via a new `SharedState` factory extracted
from `createMcpServer`.

### Fork housekeeping
- `private: true` in `package.json` so we never publish to npm.
- Updated `repository`, `bugs.url`, `homepage`, `mcpName`, `server.json`
to fork URLs/namespace; added a `contributors` entry. `author: Google
LLC` preserved.
- New `FORK.md`, `NOTICE`, `.github/CODEOWNERS`.
- README prepended with a fork banner; original README content
untouched.
- `CONTRIBUTING.md` and `SECURITY.md` rewritten for the fork.
- Deleted release/publish workflows (`pre-release.yml`,
`publish-to-npm-on-tag.yml`, `release-please.yml`,
`release-please-config.json`) since we're not publishing.
- Every modified upstream file carries a `Modifications Copyright 2026
Colin (@cejor6)` notice per Apache 2.0 §4(b).

## Test plan
- [x] `npm run typecheck` passes
- [x] `npm run build` passes
- [x] `npm run format` passes
- [x] `tests/Mutex.test.ts` — new unit tests for `Mutex`,
`MutexRegistry`, parallelism across pageIds, `acquireExclusive` draining
- [x] `tests/HttpTransport.test.ts` — `isLoopbackHost` + bearer-auth
rejection paths via a real server on an ephemeral port
- [x] `tests/ToolHandler.test.ts` — existing tests updated to construct
a `MutexRegistry`; still pass
- [x] All 27 targeted tests pass locally
- [ ] CI (run-tests, presubmit, conventional-commit) passes on this PR
- [ ] Manually verify the built binary loads cleanly under
`--experimentalPageIdRouting --http-port` once wired into Claude Code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Colin <colin@theraday.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#2)

## Summary

Per-page mutex was a no-op for any tool defined via `defineTool()` (vs
`definePageTool()`), most notably `evaluate_script`. That meant
**every** page-scoped call was draining the global exclusive lock —
strictly serialising all page work and undoing the whole point of this
fork.

## Repro (before the fix)

3 independent MCP clients over HTTP, each firing
`evaluate_script(pageId=N)` with a 2s internal sleep, on different
pages:
- Wall-clock total: **6391ms**
- t1 spread: **4253ms** — strictly serial

Same 3 calls from a single session: same pattern. Pure-puppeteer
microbenchmark (no MCP): 2084ms total, t1 spread 0ms — confirming the
bottleneck was in this fork's mutex layer, not puppeteer/Chrome.

## Root cause

`isPageScopedTool` checked only `tool.pageScoped === true`, which is set
by `definePageTool()`. `evaluate_script` uses `defineTool()` and adds
`pageId` to its schema directly when `experimentalPageIdRouting` is on
(see `src/tools/script.ts`). My handler missed that case and fell
through to `acquireExclusive()`.

## Fix

Detect "page-scoped for locking purposes" by inspecting whether the
*registered schema accepts `pageId`*, falling back to the original
`tool.pageScoped` flag. Topology tools that also accept `pageId`
(`close_page`, `select_page`) are excluded by name and continue to use
the exclusive lock.

## Verification

After the fix, same 3-client over-HTTP scenario:
- Wall-clock total: **2133ms**
- t1 spread: **0ms** — truly parallel, 3× speedup

Two regression tests added to `tests/ToolHandler.test.ts`:
1. `custom_eval` (defineTool with pageId in schema) → expects per-page
lock, not exclusive.
2. `close_page` (topology tool, also has pageId) → still expects
exclusive.

All 29 targeted tests pass locally.

## Other

- `test-output/` added to `.gitignore` and eslint global ignores. That's
where the ad-hoc parallel/serial smoke-test scripts live; they're not
part of the suite.

## Test plan
- [x] `npm run typecheck`
- [x] `npm run build`
- [x] `npm run format`
- [x] Targeted tests pass
- [ ] CI passes on this PR
- [ ] Manual: run \`test-output/parallel-test.mjs\` after merge to
confirm parallel behavior persists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Colin <colin@theraday.com>
## Summary

Adds two PowerShell scripts in \`scripts/\` that turn the fork into a
shared HTTP MCP service used by every Claude Code window on the machine.
Long-lived browser, persistent state across Code restarts, per-page
mutex actually parallelizing because requests come in concurrently from
independent client processes.

- \`setup-shared-mcp.ps1\` — generates a bearer token, writes a
launcher, registers a Windows Scheduled Task (AtLogOn +
restart-on-failure), waits for the endpoint to be reachable, and rewires
the Claude Code user MCP config via \`claude mcp add --transport http\`.
Idempotent.
- \`uninstall-shared-mcp.ps1\` — stops/removes the task, removes the MCP
entry, optionally re-adds the stdio variant, optionally cleans
token/log/profile dirs.

The launcher binds 127.0.0.1 only, requires bearer auth, and uses a
dedicated user-data-dir under \`%LOCALAPPDATA%\cdmcp\chrome-profile\` so
it never collides with the default stdio path.

FORK.md gets a Shared HTTP setup section documenting both scripts.

## Test plan
- [x] Both scripts parse-check clean via \`[Parser]::ParseFile\`
(PowerShell 5.1 compatible).
- [ ] Manual: run \`setup-shared-mcp.ps1\` on the dev machine, verify
Scheduled Task is registered, server reachable, Claude Code config
updated, browser launches on first tool call.
- [ ] Manual: open two Claude Code windows, hit chrome-devtools from
each, confirm pages from window A are visible to window B.
- [ ] Manual: run \`uninstall-shared-mcp.ps1 -RestoreStdio\` and confirm
rollback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Colin <colin@theraday.com>
Three bugs found running the setup end-to-end:

1. **Task action dropped the wscript+VBS wrapper** — Task Scheduler's
wscript context can't keep the spawned PowerShell tree alive (task
returns success while children die silently). Direct `powershell.exe
-WindowStyle Hidden` works.
2. **`claude mcp add` URL position** — the CLI takes the URL as the
second positional, not a trailing token. Applied to all three OS
variants.
3. **`Write-Host -f` cosmetic bug** — `-f` was being parsed as
ForegroundColor. Wrapped expression in parens.

Plus: launcher uses `ErrorActionPreference = 'Continue'` so the first
stderr line from node doesn't trip the NativeCommandError wrapping in
PowerShell 5.1.

Verified end-to-end: server up on 127.0.0.1:9876, claude mcp get returns
Status: Connected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Colin <colin@theraday.com>
## Problem

With many short-lived agents sharing one browser (the whole point of
this fork), `new_page` accumulates tabs — including piles of blank
`about:blank` tabs that never get reused or closed.

Root causes:

1. **Failed navigations orphan blank tabs.** `new_page` creates the tab
*first*, then calls `goto`. A failed `goto` (timeout, refused
connection, blocked-by-allowlist) threw and left the tab parked at
`about:blank`; the agent saw an error and often retried `new_page` →
another blank tab. (`navigate_page` already handles `goto` failures
gracefully — `new_page` did not.)
2. **`background` was dropped for isolated contexts.** The
`isolatedContext` path called `ctx.newPage()` without `{background}`, so
agent tabs always stole foreground focus.
3. **No reuse path exists at all** — every `new_page` spawns a fresh
tab.

## Changes

- **Failed-navigation cleanup:** wrap the navigation in `new_page`; on
failure, if the tab is still blank, close it (best-effort — the last
remaining tab is never closed) and report the failure gracefully instead
of throwing.
- **Honor `background` in both paths:** the default and isolated paths
both go through `ctx.newPage({background})`.
- **Opt-in `reuseExisting`:** new `new_page` boolean (default
**false**). When set, reuses an existing `about:blank` tab in the target
context instead of opening another. Off by default because, in a shared
isolated context, a blank tab may belong to another agent that just
opened it and hasn't navigated yet.

`isBlankUrl` moved to `src/utils/string.ts` (leaf module) to avoid an
import cycle.

## Tests

Added to `tests/tools/pages.test.ts`:
- `reuseExisting: true` reuses a blank tab (no new tab opened)
- default opens a new tab even when a blank exists
- failed navigation closes the orphaned blank tab and reports `Unable to
open`

`npm run typecheck`, eslint, prettier, and `tests/tools/pages.test.ts` +
`tests/McpContext.test.ts` all green locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Colin <colin@theraday.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scheduled task launched the server with `powershell.exe -WindowStyle
Hidden`. That powershell is the parent of the node server, so any visible
host window (e.g. a logon-time console flash) could be closed by the user,
killing the server tree. Switch the task action to
`conhost.exe --headless powershell.exe ...`, which allocates a
pseudoconsole with no window at any point. The process stays attached, so
the task remains "Running" and the restart-on-failure policy still applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cejor6 cejor6 closed this May 29, 2026
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 29, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants