fix: launch shared MCP via conhost --headless (no closable window)#2155
Closed
cejor6 wants to merge 6 commits into
Closed
fix: launch shared MCP via conhost --headless (no closable window)#2155cejor6 wants to merge 6 commits into
cejor6 wants to merge 6 commits into
Conversation
## 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>
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The shared-MCP scheduled task (
ChromeDevToolsMcpShared) launched the server withpowershell.exe -WindowStyle Hidden. Thatpowershell.exeis the parent of thenodeserver 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:
to:
conhost --headlessallocates 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 remainsRunningand the existing-RestartCount/-RestartIntervalrestart-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:
127.0.0.1:9876, task stateRunning/mcpreturns400(reachable; rejecting the empty probe body as expected)🤖 Generated with Claude Code