diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2813d1950 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Every PR auto-requests review from @cejor6. +# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +* @cejor6 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml deleted file mode 100644 index e0b680d00..000000000 --- a/.github/workflows/pre-release.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Pre-release - -permissions: read-all - -on: - workflow_dispatch: - push: - branches: - - release-please-* - -jobs: - pre-release: - name: 'Verify artifacts before release' - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - cache: npm - node-version-file: '.nvmrc' - registry-url: 'https://registry.npmjs.org' - - # Ensure npm 11.5.1 or later is installed - - name: Update npm - run: npm install -g npm@latest - - - name: Install dependencies - run: npm ci - - - name: Build and bundle - run: npm run bundle - env: - NODE_ENV: 'production' - - - name: Verify server.json - run: npm run verify-server-json-version - - - name: Verify npm package - run: npm run verify-npm-package diff --git a/.github/workflows/publish-to-npm-on-tag.yml b/.github/workflows/publish-to-npm-on-tag.yml deleted file mode 100644 index afbf0d0ef..000000000 --- a/.github/workflows/publish-to-npm-on-tag.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: publish-on-tag - -on: - push: - tags: - - 'chrome-devtools-mcp-v*' - workflow_dispatch: - inputs: - npm-publish: - description: 'Try to publish to npm' - default: false - type: boolean - mcp-publish: - description: 'Try to publish to MCP registry' - default: true - type: boolean - -permissions: - id-token: write # Required for OIDC - contents: read - -jobs: - publish-to-npm: - runs-on: ubuntu-latest - if: ${{ (github.event_name != 'workflow_dispatch') || (inputs.npm-publish && always()) }} - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - cache: npm - node-version-file: '.nvmrc' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: npm ci - - - name: Build and bundle - run: npm run bundle - env: - NODE_ENV: 'production' - - - name: Publish chrome-devtools-mcp - run: | - npm publish --provenance --access public - - - name: Publish chrome-devtools - run: | - node --input-type=module --eval ' - import * as fs from "node:fs/promises"; - const json = await fs.readFile("package.json", "utf8"); - const pkg = JSON.parse(json); - pkg.name = "chrome-devtools"; - await fs.writeFile("package.json", JSON.stringify(pkg, null, 2) + "\n"); - ' - echo 'This is the **Chrome DevTools for agents** package. Docs: https://github.com/ChromeDevTools/chrome-devtools-mcp' > README.md - npm publish --provenance --access public - - publish-to-mcp-registry: - runs-on: ubuntu-latest - needs: publish-to-npm - if: ${{ (github.event_name != 'workflow_dispatch' && needs.publish-to-npm.result == 'success') || (inputs.mcp-publish && always()) }} - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - cache: npm - node-version-file: '.nvmrc' - registry-url: 'https://registry.npmjs.org' - - # Ensure npm 11.5.1 or later is installed - - name: Update npm - run: npm install -g npm@latest - - - name: Install dependencies - run: npm ci - - - name: Build and bundle - run: npm run bundle - env: - NODE_ENV: 'production' - - - name: Install MCP Publisher - run: | - export OS=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') - curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}.tar.gz" | tar xz mcp-publisher - - - name: Login to MCP Registry - run: ./mcp-publisher login github-oidc - - - name: Publish to MCP Registry - run: ./mcp-publisher publish diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 26d4aa3cd..000000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,18 +0,0 @@ -on: - push: - branches: - - main - -permissions: read-all -name: release-please - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@v5 - with: - token: ${{ secrets.BROWSER_AUTOMATION_BOT_TOKEN }} - target-branch: main - config-file: release-please-config.json - manifest-file: .release-please-manifest.json diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b9afd88b0..887f28691 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -48,7 +48,11 @@ jobs: run: npx puppeteer browsers install chrome - name: Build - run: npm run bundle + # Fork uses `build` instead of `bundle`: we don't publish a bundled + # release, and `bundle` produces a THIRD_PARTY_NOTICES file whose + # snapshot test drifts whenever our package-lock differs from + # upstream. `build` produces everything the test suite needs. + run: npm run build env: NODE_OPTIONS: '--max_old_space_size=4096' @@ -65,8 +69,12 @@ jobs: - name: Run tests shell: bash - # Retry tests if they fail in the merge queue. - run: npm run test:no-build -- ${{ github.event_name == 'merge_group' && '--retry' || '' }} + # Always retry on flake. Upstream only enabled --retry for merge_group; + # the fork enables it for PRs too because Windows + Node 24+ shutdown + # and e2e tests are inherently slow on GH Actions runners and + # occasionally cross thresholds without any real regression. With + # --retry the test runner attempts up to 3 times. + run: npm run test:no-build -- --retry # Gating job for branch protection. test-success: diff --git a/.gitignore b/.gitignore index 2ea3f0b63..d74cd10e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ad-hoc test fixtures (mutex/transport smoke tests, screenshots) +test-output/ + # Logs logs *.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a04e2ad2..d5dd7baa8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,156 +1,57 @@ -# How to contribute +# Contributing -We'd love to accept your patches and contributions to this project. +This is a fork of [ChromeDevTools/chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp). It is not the upstream project. The upstream project's CLA-based contribution process does not apply here. -## Before you begin +## Where to send your contribution -### Sign our Contributor License Agreement +- **If your change is broadly useful** (bug fix, generally applicable feature), please open a PR against [the upstream repository](https://github.com/ChromeDevTools/chrome-devtools-mcp/pulls) directly. That benefits more people than this fork. +- **If your change is specific to this fork's additions** (per-page mutex, HTTP transport — see [FORK.md](./FORK.md)), open a PR here. -Contributions to this project must be accompanied by a -[Contributor License Agreement](https://cla.developers.google.com/about) (CLA). -You (or your employer) retain the copyright to your contribution; this simply -gives us permission to use and redistribute your contributions as part of the -project. +## Workflow -If you or your current employer have already signed the Google CLA (even if it -was for a different project), you probably don't need to do it again. +1. Fork this repo and create a feature branch. +2. Make your changes. Match the existing style (run `npm run format`). +3. Add or update tests under `tests/`. Run `npm test` locally and ensure it passes. +4. Open a PR against `main` here. +5. CI runs on the PR. The maintainer reviews and merges. -Visit to see your current agreements or to -sign a new one. +`main` is protected — no direct pushes, no force pushes, no deletion. All changes go through PRs. The single maintainer ([@cejor6](https://github.com/cejor6)) reviews and merges. -### Review our community guidelines - -This project follows -[Google's Open Source Community Guidelines](https://opensource.google/conduct/). - -## Development process - -### Code reviews - -All submissions, including submissions by project members, require review. We -use GitHub pull requests for this purpose. Consult -[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more -information on using pull requests. - -### Conventional commits - -Please follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) -for PR and commit titles. - -### Feature release checklist - -Use `chore:` for commits containing incomplete features that are not available -to users yet. Once the feature is ready to be released, create a PR with a -`feat:` prefix that enables the feature. The following criteria need to be -completed: - -- Documentation for the feature is up to date. For example, README.md and tools - reference are updated. -- The feature can be used with Chrome stable or version restrictions are - documented otherwise. -- Corresponding skills are updated or new skills are added if needed. -- The feature fulfills the use case by its own or in conjunction with existing - features (we want to avoid features that offer some tools but cannot be used - successfully to debug things). - -### Release process - -Releasing `chrome-devtools-mcp` is automated by GitHub Actions. To release a new -version, [search for a PR titled `chore(main): release chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp/pulls?q=is%3Apr+is%3Aopen+%22chore%28main%29%3A+release+chrome-devtools-mcp%22) -and review, test, and land it. The release PR is automatically opened if there -are any changes on the main branch that show up in the changelog. - -### How to update the Lighthouse dependency - -- Update the Lighthouse version in package.json and run `npm install`. The npm version is currently used for types. -- Check out the corresponding Lighthouse repository revision to a sibling directory (`../lighthouse`). -- Run `npm run update-lighthouse` (Note that Lighthouse requires yarn). -- Commit the bundle. If new dependencies are added via the bundle, update `tests/third_party_notices.test.ts`. - -## Installation - -Check that you are using node version specified in .nvmrc, then run following commands: +## Development ```sh -git clone https://github.com/ChromeDevTools/chrome-devtools-mcp.git +git clone https://github.com/cejor6/chrome-devtools-mcp.git cd chrome-devtools-mcp npm ci npm run build +npm test ``` -### Testing with @modelcontextprotocol/inspector +To skip the heavy Puppeteer Chrome download during install (you can still run the server against your system Chrome): ```sh -npx @modelcontextprotocol/inspector node /build/src/bin/chrome-devtools-mcp.js +PUPPETEER_SKIP_DOWNLOAD=true npm install ``` -### Testing with an MCP client +## TypeScript rules -Add the MCP server to your client's config. +Per the upstream conventions, maintained here too: -```json -{ - "mcpServers": { - "chrome-devtools": { - "command": "node", - "args": ["/path-to/build/src/bin/chrome-devtools-mcp.js"] - } - } -} -``` - -#### Using with VS Code SSH +- Do not use `any`, `as`, `!`, `// @ts-ignore`, `// @ts-nocheck`, or `// @ts-expect-error`. +- Prefer `for..of` over `forEach`. -When running the `@modelcontextprotocol/inspector` it spawns 2 services - one on port `6274` and one on `6277`. -Usually VS Code automatically detects and forwards `6274` but fails to detect `6277` so you need to manually forward it. +## Modifying upstream files -### Debugging +Per Apache 2.0 §4(b), any file you modify that originated upstream must carry a "Modifications Copyright" notice. Add it right below the existing `Copyright Google` block. See `src/Mutex.ts` for the established style. New files you create are entirely yours and need only your own copyright. -To write debug logs to `log.txt` in the working directory, run with the following commands: +## Keeping in sync with upstream ```sh -npx @modelcontextprotocol/inspector node /build/src/bin/chrome-devtools-mcp.js --log-file=/your/desired/path/log.txt -``` - -You can use the `DEBUG` environment variable as usual to control categories that are logged. - -### Updating documentation - -When adding a new tool or updating a tool name or description, make sure to run `npm run gen` to generate the tool reference documentation. - -### Contributing to Evals - -We use Gemini to evaluate the MCP server tools in `scripts/eval_scenarios`. -Each scenario is a TypeScript file that exports a `scenario` object implementing `TestScenario`. - -- **prompt**: The prompt to send to the model. -- **maxTurns**: Maximum number of conversation turns. -- **expectations**: A function that verifies the tool calls made by the model. -- **htmlRoute** (Optional): Serve custom HTML content for the test at a specific path. - -We look to test that the tools are used correctly without too rigid assertions. Avoid asserting exact argument values if they can vary (e.g., natural language reasoning), but ensure the core parameters (like URLs or selectors) were correct. - -Example: - -```ts -import {TestScenario} from '../eval_gemini.js'; - -export const scenario: TestScenario = { - prompt: 'Navigate to example.com', - maxTurns: 2, - expectations: calls => { - // Check that at least one call was 'browse_page' - const navigation = calls.find(c => c.name === 'browse_page'); - if (!navigation) throw new Error('Model did not browse the page'); - // Verify essential args - if (navigation.args.url !== 'http://example.com') { - throw new Error(`Wrong URL: ${navigation.args.url}`); - } - }, -}; +git fetch upstream +git checkout main +git merge upstream/main +# resolve conflicts; opens as a PR via your fork-of-this-fork workflow +git push origin main ``` -## Restrictions on JSON schema - -- no .nullable(), no .object() types. Enforced by the `@local/enforce-zod-schema` ESLint rule. -- represent complex object as a short formatted string. +(Note: only the maintainer can push to `main` — they'll handle upstream merges.) diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..3337925bf --- /dev/null +++ b/FORK.md @@ -0,0 +1,100 @@ +# Fork notes + +This is a fork of [ChromeDevTools/chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp), maintained by [@cejor6](https://github.com/cejor6). + +The goal is to make `chrome-devtools-mcp` robust enough for **multiple agents to drive different pages in the same Chrome instance in parallel**, including across separate client processes. + +## What this fork adds + +### 1. Per-page mutex (`MutexRegistry`) + +Upstream uses a single global mutex that serializes every tool call. Two agents working on two different pages step on each other (one waits while the other runs). + +This fork replaces the single mutex with a `MutexRegistry` that: + +- Hands out a separate mutex per `pageId` for page-scoped tools (`evaluate_script`, `click`, `take_snapshot`, etc.). +- Uses a global mutex for topology-changing tools (`new_page`, `close_page`, `select_page`, `list_pages`). +- Drains all per-page mutexes when a topology tool runs, so page topology never mutates while page work is in flight. + +**Effect:** with `--experimentalPageIdRouting` enabled, two page-scoped tool calls on different pages truly run in parallel. Tested via `tests/Mutex.test.ts`. + +**Backward compatibility:** when `--experimentalPageIdRouting` is off, behavior matches upstream exactly (single-flight via the global mutex). + +### 2. Streamable HTTP transport + +Upstream is stdio-only. Stdio only allows one client process at a time, so independent Claude Code windows / external clients can't share one browser. + +This fork adds an HTTP transport (Streamable HTTP per the MCP spec) running alongside stdio: + +``` +chrome-devtools-mcp --http-port 9876 --http-token "$MCP_TOKEN" +``` + +Flags: + +- `--http-port `: enable HTTP transport on this port. Stdio remains active. +- `--http-host `: bind address (default `127.0.0.1`). Non-loopback requires `--http-token`. +- `--http-token `: bearer token required in `Authorization: Bearer `. + +Each HTTP session gets its own `McpServer` instance but shares the same Chrome browser via `SharedState` (extracted in `src/index.ts`). + +### 3. `SharedState` factory + +`createSharedState()` lazily launches/connects Chrome and owns the `MutexRegistry`. Multiple servers (stdio + N HTTP sessions) call into the same state, so the browser is launched once and all sessions cooperate via the same mutex registry. + +### 4. Page-lifecycle hygiene for many short-lived agents + +With dozens of agents sharing one browser, `new_page` accumulated tabs: + +- **Failed navigations no longer orphan blank tabs.** Upstream's `new_page` creates the tab first, then calls `goto`; a failed `goto` (timeout, refused connection, blocked navigation) threw and left the tab parked at `about:blank`, and agents would retry — multiplying blank tabs. The fork wraps the navigation, and if it fails while the tab is still blank, closes that tab (best-effort; the last tab is never closed) and reports the failure gracefully instead of throwing. +- **`background` is honored for isolated contexts.** Upstream dropped the `background` flag on the `isolatedContext` path, so agent tabs always stole foreground focus. The fork passes `{background}` through both paths. +- **Opt-in tab reuse.** `new_page` accepts `reuseExisting` (default `false`): when set, it reuses an existing blank (`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. + +These changes touch `src/McpContext.ts`, `src/tools/pages.ts`, and `src/tools/ToolDefinition.ts`. Tested via `tests/tools/pages.test.ts`. + +## Keeping in sync with upstream + +```sh +git fetch upstream +git checkout main +git merge upstream/main +# resolve conflicts (typically minimal; modified files are clearly marked) +git push origin main +``` + +The modifications are concentrated in a handful of files: + +- `src/Mutex.ts` (additions only) +- `src/ToolHandler.ts` (constructor + handle() locking strategy) +- `src/index.ts` (refactor to expose SharedState) +- `src/bin/chrome-devtools-mcp-main.ts` (optionally start HTTP transport) +- `src/bin/chrome-devtools-mcp-cli-options.ts` (http-\* flags + validation) +- `src/third_party/index.ts` (re-export `StreamableHTTPServerTransport`) +- `src/HttpTransport.ts` (new file — entirely fork-owned) +- `src/McpContext.ts` (`newPage` reuse + background; `isBlankUrl` helper) +- `src/tools/pages.ts` (`new_page` failed-navigation cleanup + `reuseExisting`) +- `src/tools/ToolDefinition.ts` (`Context` interface: `getPageId`, `newPage` arg) + +## Attribution + +Each modified file carries a `Modifications Copyright 2026 Colin (@cejor6)` notice in addition to the original `Copyright Google LLC` header. New files are copyright Colin (@cejor6). See `NOTICE` for the consolidated list. + +## Shared HTTP setup + +For the per-page mutex to deliver actual parallelism across multiple Claude Code windows, the server has to be shared — one long-lived process that every session connects to over HTTP. The repo ships per-OS setup scripts in [`scripts/`](./scripts/README.md): + +| OS | Setup | Uninstall | +| --------------- | ----------------------------------- | --------------------------------------- | +| Windows | `scripts\setup-shared-mcp.ps1` | `scripts\uninstall-shared-mcp.ps1` | +| macOS | `scripts/setup-shared-mcp.macos.sh` | `scripts/uninstall-shared-mcp.macos.sh` | +| Linux (systemd) | `scripts/setup-shared-mcp.linux.sh` | `scripts/uninstall-shared-mcp.linux.sh` | + +All variants register a per-user OS service (Task Scheduler / launchd / systemd user), bind to `127.0.0.1:9876` only, require a bearer token (stored 0600 / Windows ACL: user-only), and use a dedicated `--user-data-dir` so the shared profile never collides with the default stdio Chrome profile. The Claude Code user MCP config is rewritten atomically via the `claude mcp` CLI. + +See [`scripts/README.md`](./scripts/README.md) for OS-specific file locations, common knobs (`PORT`, `FORK_PATH`, `FORCE`), and rollback flags. + +## Issues and contributions + +Open an issue at . PRs welcome — `main` is protected; everything goes through a PR. Maintainer reviews and merges. + +For upstream-relevant changes (bug fixes, broadly useful features), please consider opening a PR against [the upstream repo](https://github.com/ChromeDevTools/chrome-devtools-mcp) instead. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..4e4f96a0c --- /dev/null +++ b/NOTICE @@ -0,0 +1,22 @@ +chrome-devtools-mcp (fork) +Copyright 2025-2026 Google LLC +Copyright 2026 Colin (@cejor6) (modifications) + +This product includes software originally developed by Google LLC as +"chrome-devtools-mcp" (https://github.com/ChromeDevTools/chrome-devtools-mcp), +licensed under the Apache License, Version 2.0. + +Modifications by Colin (@cejor6) (https://github.com/cejor6) include: + - Replaced the single global tool mutex with a MutexRegistry that hands + out per-page mutexes plus a global "exclusive" mode for topology + operations. Page-scoped tools acting on different pages now run + concurrently when --experimentalPageIdRouting is enabled. + - Added a Streamable HTTP transport (alongside stdio) that supports + multiple concurrent MCP client sessions sharing one Chrome browser, + with optional bearer-token authentication and host validation. + - Extracted shared state (browser/context lifecycle + MutexRegistry) + from createMcpServer so multiple McpServer instances can share one + browser. + +Each modified file carries an additional copyright notice describing the +modifications made to it, per Apache License 2.0 §4(b). diff --git a/README.md b/README.md index 1ffbafa00..71bcba046 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ -# Chrome DevTools for agents +# Chrome DevTools for agents — fork + +> **This is a fork of [ChromeDevTools/chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp), maintained by [@cejor6](https://github.com/cejor6).** It adds a per-page mutex (concurrent page-scoped tool calls across different pages) and a Streamable HTTP transport (multiple client processes sharing one browser). See **[FORK.md](./FORK.md)** for divergence details and the **[NOTICE](./NOTICE)** for attribution. The original README from the upstream project follows below, unmodified. +> +> The fork is intentionally not published to npm (`"private": true`). Install by cloning and running locally. + +--- [![npm chrome-devtools-mcp package](https://img.shields.io/npm/v/chrome-devtools-mcp.svg)](https://npmjs.org/package/chrome-devtools-mcp) @@ -684,6 +690,18 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `false` +- **`--httpPort`/ `--http-port`** + If set, also expose the MCP server over HTTP (Streamable HTTP transport) on this port. Stdio remains active simultaneously. Multiple concurrent sessions share one browser via shared state. + - **Type:** number + +- **`--httpHost`/ `--http-host`** + Hostname/IP for HTTP transport to bind to. Defaults to 127.0.0.1 (loopback only) when --http-port is set. Use 0.0.0.0 to expose on all interfaces — REQUIRES --http-token. + - **Type:** string + +- **`--httpToken`/ `--http-token`** + Bearer token required in Authorization header for HTTP clients. Strongly recommended; required when --http-host is non-loopback. + - **Type:** string + Pass them via the `args` property in the JSON configuration. For example: diff --git a/SECURITY.md b/SECURITY.md index 2dd648c79..4c4e90881 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,24 @@ -## Security policy +# Security policy -The Chrome DevTools MCP project takes security very seriously. Please use [Chromium’s process to report security issues](https://www.chromium.org/Home/chromium-security/reporting-security-bugs/). +This is a fork of [ChromeDevTools/chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp). The security posture follows upstream's, with one addition: this repo has **private vulnerability reporting enabled on GitHub**. -### Scope +## Reporting a vulnerability + +- **For issues specific to this fork** (e.g., the HTTP transport's bearer auth, the per-page mutex behavior): use GitHub's private vulnerability reporting on this repo — . +- **For issues affecting upstream `chrome-devtools-mcp`**: please report through [Chromium's security bug process](https://www.chromium.org/Home/chromium-security/reporting-security-bugs/) so the broader user base benefits from the fix. + +## Scope In general, it is the expectation that the AI agent or client using this MCP server validates any input (including tool calls and parameters) before sending it. The server provides powerful capabilities for browser automation and inspection, and it is the responsibility of the calling agent to ensure these are used safely and as intended. -Several tools in this project have the ability to perform actions such as writing files to disk (e.g., via browser downloads or screenshots) or dynamically loading Chrome extensions. These are intentional, documented features and are not vulnerabilities. +Several tools have the ability to perform actions such as writing files to disk (e.g., via browser downloads or screenshots) or dynamically loading Chrome extensions. These are intentional, documented features and are not vulnerabilities. + +## HTTP transport specific + +The HTTP transport added in this fork (see [FORK.md](./FORK.md)) binds to `127.0.0.1` by default and requires a bearer token (`--http-token`) when binding to a non-loopback address. Treat the token as a credential — anyone with it can drive your browser. + +We will treat the following as in-scope vulnerabilities for this fork: -We appreciate feedback and suggestions from developers on how this tool can make it easier for them to build a more secure user experience, but will treat these exclusively as feature requests, and not vulnerabilities in chrome-devtools-mcp itself. +- Auth bypass on the HTTP transport. +- Inadvertent exposure of the HTTP transport on non-loopback interfaces without a token. +- Per-page mutex correctness issues that allow a tool call to operate on the wrong page. diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 747193452..eb6dfc7e5 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -220,6 +220,7 @@ - **url** (string) **(required)**: URL to load in a new page. - **background** (boolean) _(optional)_: Whether to open the page in the background without bringing it to the front. Default is false (foreground). - **isolatedContext** (string) _(optional)_: If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated. +- **reuseExisting** (boolean) _(optional)_: Reuse an existing blank (about:blank) tab in the target context instead of opening a new one, when such a tab exists. Avoids accumulating idle tabs. Defaults to false. Note: in a shared isolated context the blank tab may belong to another agent, so only enable this when you own the context. - **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 6009e720c..6c2cff5fb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,8 @@ export default defineConfig([ '**/build/', 'tests/tools/fixtures/', 'src/third_party/lighthouse-devtools-mcp-bundle.js', + // Ad-hoc local smoke-test scripts (gitignored; not part of the suite). + 'test-output/', ]), importPlugin.flatConfigs.typescript, { diff --git a/package.json b/package.json index afd02f5ea..63ae51547 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "chrome-devtools-mcp", "version": "1.1.0", - "description": "MCP server for Chrome DevTools", + "private": true, + "description": "Fork of chrome-devtools-mcp with per-page mutex and Streamable HTTP transport for concurrent multi-agent sessions. Upstream: ChromeDevTools/chrome-devtools-mcp.", "type": "module", "bin": { "chrome-devtools-mcp": "./build/src/bin/chrome-devtools-mcp.js", @@ -38,14 +39,20 @@ "!*.tsbuildinfo", "!*.js.map" ], - "repository": "ChromeDevTools/chrome-devtools-mcp", + "repository": "cejor6/chrome-devtools-mcp", "author": "Google LLC", + "contributors": [ + { + "name": "Colin (@cejor6)", + "url": "https://github.com/cejor6" + } + ], "license": "Apache-2.0", "bugs": { - "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp/issues" + "url": "https://github.com/cejor6/chrome-devtools-mcp/issues" }, - "homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme", - "mcpName": "io.github.ChromeDevTools/chrome-devtools-mcp", + "homepage": "https://github.com/cejor6/chrome-devtools-mcp#readme", + "mcpName": "io.github.cejor6/chrome-devtools-mcp", "devDependencies": { "@eslint/js": "^9.35.0", "@google/genai": "^2.0.1", diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 5abfdd2af..000000000 --- a/release-please-config.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "changelog-sections": [ - {"type": "feat", "section": "🎉 Features", "hidden": false}, - {"type": "fix", "section": "🛠️ Fixes", "hidden": false}, - {"type": "docs", "section": "📄 Documentation", "hidden": false}, - - {"type": "perf", "section": "⚡ Performance", "hidden": false}, - {"type": "refactor", "section": "🏗️ Refactor", "hidden": false}, - {"type": "chore", "section": "♻️ Chores", "hidden": true}, - {"type": "test", "section": "♻️ Chores", "hidden": true}, - - {"type": "build", "section": "⚙️ Automation", "hidden": true}, - {"type": "ci", "section": "⚙️ Automation", "hidden": true} - ], - - "packages": { - ".": { - "extra-files": [ - { - "type": "generic", - "path": "src/version.ts" - }, - { - "type": "json", - "path": "server.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": "server.json", - "jsonpath": "packages[0].version" - }, - { - "type": "json", - "path": ".claude-plugin/marketplace.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": ".claude-plugin/plugin.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": ".claude-plugin/plugin.json", - "jsonpath": "mcpServers['chrome-devtools'].args[1]" - }, - { - "type": "json", - "path": ".cursor-plugin/plugin.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": ".cursor-plugin/plugin.json", - "jsonpath": "mcpServers.[['chrome-devtools']'].args[1]" - }, - { - "type": "json", - "path": "gemini-extension.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": "gemini-extension.json", - "jsonpath": "mcpServers['chrome-devtools'].args[1]" - }, - { - "type": "json", - "path": ".github/plugin/plugin.json", - "jsonpath": "version" - }, - { - "type": "json", - "path": ".github/plugin/plugin.json", - "jsonpath": "mcpServers['chrome-devtools'].args[1]" - } - ] - } - } -} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..8689a052d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,71 @@ +# Fork tooling: shared HTTP MCP setup + +These scripts wire up the fork as a **long-lived HTTP MCP service** shared by every Claude Code session on the machine. After setup: + +- One Chrome instance, persistent across Claude Code restarts. +- Every Claude Code window connects to the same server over HTTP+bearer. +- The per-page mutex actually delivers parallelism across sessions (proven ~3× on the smoke tests under `test-output/`). + +## Pick the script for your OS + +| OS | Setup | Uninstall | Service backend | +| ------------------- | --------------------------- | ------------------------------- | --------------------------------------------- | +| **Windows** | `setup-shared-mcp.ps1` | `uninstall-shared-mcp.ps1` | Task Scheduler (AtLogOn + restart-on-failure) | +| **macOS** | `setup-shared-mcp.macos.sh` | `uninstall-shared-mcp.macos.sh` | launchd user agent (`~/Library/LaunchAgents`) | +| **Linux** (systemd) | `setup-shared-mcp.linux.sh` | `uninstall-shared-mcp.linux.sh` | systemd user service (`systemctl --user`) | + +All variants do the same thing: + +1. Generate a 32-byte bearer token (mode 0600 / Windows ACL: user-only). +2. Pick a port (default `9876`), bind to `127.0.0.1` only. +3. Use a dedicated `--user-data-dir` so the shared server doesn't collide with the default stdio Chrome profile. +4. Register a per-user OS service that starts at logon and restarts on failure. +5. Wait for the HTTP endpoint to become reachable. +6. Atomically rewrite the Claude Code user MCP config via `claude mcp add --transport http --header "Authorization: Bearer …"`. + +All variants are idempotent — safe to re-run to update settings or rotate the token (pass `Force` / `FORCE=1`). + +## Prerequisites + +- `node` and `npm` on PATH. +- The fork cloned and built: `npm run build` in the fork's directory. +- `claude` CLI on PATH. +- macOS / Linux: `openssl` and `curl` (almost always already there). + +## Common knobs + +| Variable | Default | What it does | +| ------------------------------------ | --------------------------------------- | ------------------------------------------------------------ | +| `PORT` (sh) / `-Port` (ps1) | `9876` | TCP port on `127.0.0.1` | +| `FORK_PATH` (sh) / `-ForkPath` (ps1) | repo root resolved from script location | Path to the cloned fork | +| `FORCE=1` (sh) / `-Force` (ps1) | off | Regenerate the bearer token instead of reusing the saved one | + +## File locations + +| Purpose | Windows | macOS | Linux | +| ----------------------- | ------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- | +| Token | `%APPDATA%\cdmcp\token` | `~/Library/Application Support/cdmcp/token` | `~/.config/cdmcp/token` | +| Launcher / service unit | `%APPDATA%\cdmcp\launcher.ps1` | `~/Library/LaunchAgents/dev.cejor6.chromedevtoolsmcp.plist` | `~/.config/systemd/user/chrome-devtools-mcp.service` | +| Chrome profile | `%LOCALAPPDATA%\cdmcp\chrome-profile` | `~/Library/Application Support/cdmcp/chrome-profile` | `~/.local/share/cdmcp/chrome-profile` | +| Logs | `%LOCALAPPDATA%\cdmcp\logs\` | `~/Library/Logs/cdmcp/` | `~/.local/state/cdmcp/logs/` | + +## Rolling back + +Each uninstall script accepts `-RestoreStdio` (PowerShell) or `RESTORE_STDIO=1` (bash) to re-add the stdio variant of `chrome-devtools` to the Claude Code config after removing the HTTP version. Without that flag it just removes the entry — you'd add whatever you want with `claude mcp add` afterwards. + +`-KeepTokenAndLogs` (PowerShell) / `KEEP_DATA=1` (bash) skips the prompt that offers to delete the token and log directories. + +## Verification status + +| OS | Status | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Windows | ✅ Verified end-to-end on Windows 11 / PowerShell 5.1 / Node 22. Scheduled Task → powershell launcher → node, server reachable, `claude mcp get` reports Status: Connected. | +| macOS | ⚠️ Unverified — same logical fixes applied as Windows (URL position in `claude mcp add`, etc.), parse-checked with `bash -n`. launchd has no analog of the Windows wscript-orphan bug. Please file an issue if it fails. | +| Linux | ⚠️ Unverified — same as macOS. systemd has no analog of the Windows wscript-orphan bug. Please file an issue if it fails. | + +## Caveats + +- All Claude Code windows on the machine will share **one Chrome profile**. Cookies, login sessions, and tabs are visible across sessions. That's the whole point — see [`../CLAUDE.md`](../CLAUDE.md) for the multi-session etiquette rules agents should follow. +- The browser process becomes long-lived. To bounce it: restart the OS service (`schtasks /Run`, `launchctl kickstart -k`, `systemctl --user restart`) or just kill the Chrome process — the service will restart it. +- Token rotation: re-run setup with `Force`/`FORCE=1`. Already-running Claude Code windows hold the old token and will need to be restarted. +- Linux non-systemd init systems (sysvinit, OpenRC, runit) are not supported by the script directly — adapt the systemd unit to your init manager. diff --git a/scripts/setup-shared-mcp.linux.sh b/scripts/setup-shared-mcp.linux.sh new file mode 100644 index 000000000..fdd08ad01 --- /dev/null +++ b/scripts/setup-shared-mcp.linux.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Set up chrome-devtools-mcp as a long-lived HTTP service shared by every +# Claude Code session on this machine. Linux (systemd user service) variant. +# +# Requirements: systemd-based distro with user services (typical Ubuntu / +# Fedora / Arch desktop), `node`, `openssl`, `curl`, and the `claude` CLI +# on PATH. Build the fork first: `npm run build`. +# +# Usage: +# ./scripts/setup-shared-mcp.linux.sh +# PORT=9000 FORCE=1 ./scripts/setup-shared-mcp.linux.sh +# +set -euo pipefail + +PORT="${PORT:-9876}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FORK_PATH="${FORK_PATH:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +CONFIG_DIR="$HOME/.config/cdmcp" +TOKEN_FILE="$CONFIG_DIR/token" +LOG_DIR="$HOME/.local/state/cdmcp/logs" +PROFILE_DIR="$HOME/.local/share/cdmcp/chrome-profile" +SERVICE_DIR="$HOME/.config/systemd/user" +SERVICE_FILE="$SERVICE_DIR/chrome-devtools-mcp.service" + +CDMCP_JS="$FORK_PATH/build/src/bin/chrome-devtools-mcp.js" +[[ -f "$CDMCP_JS" ]] || { echo "Fork build not found at $CDMCP_JS. Run 'npm run build' in $FORK_PATH first."; exit 1; } +NODE="$(command -v node)" +[[ -n "$NODE" ]] || { echo "node not found on PATH"; exit 1; } +command -v systemctl >/dev/null || { echo "systemctl not found; this script requires systemd."; exit 1; } +command -v claude >/dev/null || { echo "claude CLI not found on PATH"; exit 1; } + +mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$PROFILE_DIR" "$SERVICE_DIR" + +if [[ ! -f "$TOKEN_FILE" || "${FORCE:-0}" == "1" ]]; then + TOKEN="$(openssl rand -hex 32)" + printf '%s' "$TOKEN" > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + echo "Token: generated ($TOKEN_FILE)" +else + TOKEN="$(cat "$TOKEN_FILE")" + echo "Token: reused existing ($TOKEN_FILE)" +fi + +cat > "$SERVICE_FILE" </dev/null +systemctl --user restart chrome-devtools-mcp.service +echo "Service: enabled + restarted" + +echo -n "Waiting for HTTP endpoint... " +ready=false +for _ in {1..60}; do + status=$(curl -sf -o /dev/null -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' \ + --max-time 2 \ + "http://127.0.0.1:$PORT/mcp" 2>/dev/null || echo "000") + if [[ "$status" =~ ^[0-9]+$ && "$status" -ge 200 && "$status" -lt 500 ]]; then + ready=true + break + fi + sleep 0.5 +done +$ready || { echo "FAILED"; echo "See log: $LOG_DIR/server.log"; exit 1; } +echo "ready" + +claude mcp remove chrome-devtools --scope user >/dev/null 2>&1 || true +# URL is the second positional after the server name; the CLI doesn't +# accept it as a trailing token after the flags. +claude mcp add chrome-devtools "http://127.0.0.1:$PORT/mcp" \ + --scope user \ + --transport http \ + --header "Authorization: Bearer $TOKEN" +echo "Claude Code: chrome-devtools rewired to http://127.0.0.1:$PORT/mcp" + +echo +echo "=== Setup complete ===" +echo " Token file: $TOKEN_FILE" +echo " Profile dir: $PROFILE_DIR" +echo " Logs: $LOG_DIR/server.log" +echo " Service: systemctl --user status chrome-devtools-mcp" +echo +echo "Restart any open Claude Code windows to pick up the new MCP config." +echo "To uninstall: ./scripts/uninstall-shared-mcp.linux.sh" diff --git a/scripts/setup-shared-mcp.macos.sh b/scripts/setup-shared-mcp.macos.sh new file mode 100644 index 000000000..761a60028 --- /dev/null +++ b/scripts/setup-shared-mcp.macos.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Set up chrome-devtools-mcp as a long-lived HTTP service shared by every +# Claude Code session on this machine. macOS (launchd user agent) variant. +# +# Requirements: macOS, `node`, `openssl`, `curl`, `claude` CLI on PATH. +# Build the fork first: `npm run build`. +# +# Usage: +# ./scripts/setup-shared-mcp.macos.sh +# PORT=9000 FORCE=1 ./scripts/setup-shared-mcp.macos.sh +# +set -euo pipefail + +PORT="${PORT:-9876}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FORK_PATH="${FORK_PATH:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +CONFIG_DIR="$HOME/Library/Application Support/cdmcp" +TOKEN_FILE="$CONFIG_DIR/token" +LOG_DIR="$HOME/Library/Logs/cdmcp" +PROFILE_DIR="$CONFIG_DIR/chrome-profile" +LAUNCH_DIR="$HOME/Library/LaunchAgents" +LABEL="dev.cejor6.chromedevtoolsmcp" +PLIST_FILE="$LAUNCH_DIR/$LABEL.plist" + +CDMCP_JS="$FORK_PATH/build/src/bin/chrome-devtools-mcp.js" +[[ -f "$CDMCP_JS" ]] || { echo "Fork build not found at $CDMCP_JS. Run 'npm run build' in $FORK_PATH first."; exit 1; } +NODE="$(command -v node)" +[[ -n "$NODE" ]] || { echo "node not found on PATH"; exit 1; } +command -v launchctl >/dev/null || { echo "launchctl not found"; exit 1; } +command -v claude >/dev/null || { echo "claude CLI not found on PATH"; exit 1; } + +mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$PROFILE_DIR" "$LAUNCH_DIR" + +if [[ ! -f "$TOKEN_FILE" || "${FORCE:-0}" == "1" ]]; then + TOKEN="$(openssl rand -hex 32)" + printf '%s' "$TOKEN" > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + echo "Token: generated ($TOKEN_FILE)" +else + TOKEN="$(cat "$TOKEN_FILE")" + echo "Token: reused existing ($TOKEN_FILE)" +fi + +cat > "$PLIST_FILE" < + + + + Label + $LABEL + ProgramArguments + + $NODE + $CDMCP_JS + --experimentalPageIdRouting + --http-port$PORT + --http-host127.0.0.1 + --http-token$TOKEN + --user-data-dir$PROFILE_DIR + + EnvironmentVariables + + CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICStrue + + RunAtLoad + KeepAlive + + SuccessfulExit + + ThrottleInterval10 + StandardOutPath$LOG_DIR/server.log + StandardErrorPath$LOG_DIR/server.log + + +EOF + +echo "LaunchAgent: $PLIST_FILE" + +launchctl unload "$PLIST_FILE" 2>/dev/null || true +launchctl load "$PLIST_FILE" +echo "LaunchAgent: loaded" + +echo -n "Waiting for HTTP endpoint... " +ready=false +for _ in {1..60}; do + status=$(curl -sf -o /dev/null -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' \ + --max-time 2 \ + "http://127.0.0.1:$PORT/mcp" 2>/dev/null || echo "000") + if [[ "$status" =~ ^[0-9]+$ && "$status" -ge 200 && "$status" -lt 500 ]]; then + ready=true + break + fi + sleep 0.5 +done +$ready || { echo "FAILED"; echo "See log: $LOG_DIR/server.log"; exit 1; } +echo "ready" + +claude mcp remove chrome-devtools --scope user >/dev/null 2>&1 || true +# URL is the second positional after the server name; the CLI doesn't +# accept it as a trailing token after the flags. +claude mcp add chrome-devtools "http://127.0.0.1:$PORT/mcp" \ + --scope user \ + --transport http \ + --header "Authorization: Bearer $TOKEN" +echo "Claude Code: chrome-devtools rewired to http://127.0.0.1:$PORT/mcp" + +echo +echo "=== Setup complete ===" +echo " Token file: $TOKEN_FILE" +echo " Profile dir: $PROFILE_DIR" +echo " Logs: $LOG_DIR/server.log" +echo " LaunchAgent: launchctl list | grep $LABEL" +echo +echo "Restart any open Claude Code windows to pick up the new MCP config." +echo "To uninstall: ./scripts/uninstall-shared-mcp.macos.sh" diff --git a/scripts/setup-shared-mcp.ps1 b/scripts/setup-shared-mcp.ps1 new file mode 100644 index 000000000..dcae6f642 --- /dev/null +++ b/scripts/setup-shared-mcp.ps1 @@ -0,0 +1,232 @@ +<# +.SYNOPSIS + Set up chrome-devtools-mcp as a long-lived HTTP service shared by every + Claude Code session on this machine. + +.DESCRIPTION + Configures a Windows Scheduled Task that launches the fork's MCP server + with --http-port at logon, restarts on failure, and binds to 127.0.0.1 + only. Generates a bearer token, stores it under %APPDATA%\cdmcp\token, + and rewrites the chrome-devtools entry in the Claude Code user MCP + config to point at the HTTP endpoint. + + Idempotent: safe to re-run to update settings. + +.PARAMETER Port + TCP port to bind on 127.0.0.1. Default 9876. + +.PARAMETER ForkPath + Path to the cloned chrome-devtools-mcp fork repo. Must already be built + (`npm run build`). + +.PARAMETER Force + Recreate the token even if one already exists. + +.EXAMPLE + .\scripts\setup-shared-mcp.ps1 + +.EXAMPLE + .\scripts\setup-shared-mcp.ps1 -Port 9000 -Force +#> +param( + [int]$Port = 9876, + [string]$ForkPath = '', + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +if (-not $ForkPath) { + if ($PSScriptRoot) { + $ForkPath = Split-Path -Parent $PSScriptRoot + } else { + $ForkPath = 'C:\Users\cejor\Dev\chrome-devtools-mcp' + } +} +$ForkPath = (Resolve-Path -LiteralPath $ForkPath).Path + +$ConfigDir = Join-Path $env:APPDATA 'cdmcp' +$TokenFile = Join-Path $ConfigDir 'token' +$LauncherFile = Join-Path $ConfigDir 'launcher.ps1' +$LogDir = Join-Path $env:LOCALAPPDATA 'cdmcp\logs' +$ProfileDir = Join-Path $env:LOCALAPPDATA 'cdmcp\chrome-profile' +$TaskName = 'ChromeDevToolsMcpShared' + +Write-Host '=== Chrome DevTools MCP — Shared HTTP Setup ===' -ForegroundColor Cyan +Write-Host '' + +# --- 1. Verify the fork is built --------------------------------------------- +$cdmcpJs = Join-Path $ForkPath 'build\src\bin\chrome-devtools-mcp.js' +if (-not (Test-Path $cdmcpJs)) { + throw "Fork build not found at $cdmcpJs. Run ``npm run build`` in $ForkPath first." +} +Write-Host "Fork build: $cdmcpJs" + +# --- 2. Ensure directories exist --------------------------------------------- +foreach ($dir in @($ConfigDir, $LogDir, $ProfileDir)) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } +} + +# --- 3. Generate or reuse token --------------------------------------------- +if ((Test-Path $TokenFile) -and (-not $Force)) { + $token = (Get-Content $TokenFile -Raw).Trim() + Write-Host "Token: reused existing ($TokenFile)" +} else { + $bytes = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $token = -join ($bytes | ForEach-Object { '{0:x2}' -f $_ }) + + [System.IO.File]::WriteAllText($TokenFile, $token, [System.Text.UTF8Encoding]::new($false)) + + # Restrict ACL to current user only. + $acl = Get-Acl $TokenFile + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "$env:USERDOMAIN\$env:USERNAME", 'FullControl', 'Allow' + ) + $acl.SetAccessRule($rule) + Set-Acl $TokenFile $acl + + Write-Host "Token: generated ($TokenFile)" +} + +# --- 4. Write launcher script ----------------------------------------------- +# We embed paths via single-quoted strings so PowerShell doesn't interpret +# $-variables in them. The launcher reads the token at run time. +$launcher = @" +# Auto-generated by setup-shared-mcp.ps1. Do not edit by hand. +# IMPORTANT: ErrorActionPreference must be Continue, not Stop. Windows +# PowerShell 5.1 wraps each line a native process writes to stderr as a +# NativeCommandError ErrorRecord; with Stop, the disclaimer that the MCP +# server writes during startup (e.g. "turning off usage statistics") would +# terminate this script before node finishes initialising the HTTP server. +`$ErrorActionPreference = 'Continue' +`$token = (Get-Content '$TokenFile' -Raw).Trim() +`$logFile = Join-Path '$LogDir' ("server-" + (Get-Date -Format 'yyyyMMdd') + ".log") +`$env:CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS = 'true' +& node '$cdmcpJs' `` + --experimentalPageIdRouting `` + --http-port $Port `` + --http-host 127.0.0.1 `` + --http-token `$token `` + --user-data-dir '$ProfileDir' `` + *>> `$logFile +"@ +$launcher | Out-File -Encoding utf8 -FilePath $LauncherFile -Force +Write-Host "Launcher: $LauncherFile" + +# --- 5. Register the Scheduled Task ------------------------------------------ +# Task action launches PowerShell via `conhost.exe --headless`. The +# headless conhost allocates a pseudoconsole with NO window ever, so +# there is no logon-time flash and nothing for the user to accidentally +# close (closing a visible host window is what kills the server tree). +# The powershell + node tree stays attached to the task, so the task +# remains "Running" for as long as `& node ...` is alive inside the +# launcher and the -RestartCount/-RestartInterval restart-on-failure +# policy below still applies. +# +# An earlier iteration used a wscript.exe + .vbs wrapper to avoid the +# flash, but Task Scheduler's wscript context can't reliably spawn +# long-lived child processes (it returns "success" while the spawned +# tree dies almost immediately). conhost --headless avoids that: it is +# the real process host, not a fire-and-forget launcher. +$action = New-ScheduledTaskAction ` + -Execute 'conhost.exe' ` + -Argument "--headless powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$LauncherFile`"" + +$trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" + +$settings = New-ScheduledTaskSettingsSet ` + -StartWhenAvailable ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -MultipleInstances IgnoreNew + +$principal = New-ScheduledTaskPrincipal ` + -UserId "$env:USERDOMAIN\$env:USERNAME" ` + -LogonType Interactive ` + -RunLevel Limited + +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false +} +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Description 'chrome-devtools-mcp shared HTTP server (cejor6 fork)' | Out-Null +Write-Host "Scheduled Task: $TaskName (registered)" + +# --- 6. Stop any prior instance and start fresh ------------------------------ +try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction Stop } catch {} +Start-ScheduledTask -TaskName $TaskName +Write-Host "Scheduled Task: started" + +# --- 7. Wait for the server to respond --------------------------------------- +# 4xx counts as "reachable" — the server is up, just rejecting our +# deliberately malformed probe body. Inspect the response on a single +# broad catch so this works on both Windows PowerShell 5.1 +# (System.Net.WebException) and PowerShell 7+ +# (Microsoft.PowerShell.Commands.HttpResponseException) without +# referencing the PS7-only type at parse time. +$serverReady = $false +for ($i = 0; $i -lt 60; $i++) { + try { + $null = Invoke-WebRequest ` + -Uri "http://127.0.0.1:$Port/mcp" ` + -Method POST ` + -Headers @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } ` + -Body '{}' ` + -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + $serverReady = $true + break + } catch { + $resp = $null + try { $resp = $_.Exception.Response } catch {} + if ($resp -and $resp.StatusCode) { + $status = [int]$resp.StatusCode + if ($status -ge 200 -and $status -lt 500) { + $serverReady = $true + break + } + } + Start-Sleep -Milliseconds 500 + } +} +if (-not $serverReady) { + throw "Server did not become reachable on http://127.0.0.1:$Port within 30 seconds. Check the log at $LogDir." +} +Write-Host "HTTP endpoint: http://127.0.0.1:$Port/mcp (reachable)" + +# --- 8. Update Claude Code MCP config ---------------------------------------- +# The `claude mcp add` CLI takes the URL as the second positional +# argument (right after the server name), not as a trailing token after +# the flags. +& claude mcp remove chrome-devtools --scope user 2>$null | Out-Null +& claude mcp add chrome-devtools "http://127.0.0.1:$Port/mcp" ` + --scope user ` + --transport http ` + --header "Authorization: Bearer $token" + +if ($LASTEXITCODE -ne 0) { + throw "claude mcp add failed." +} +Write-Host "Claude Code: chrome-devtools entry rewritten to HTTP" + +# --- 9. Summary -------------------------------------------------------------- +Write-Host '' +Write-Host '=== Setup complete ===' -ForegroundColor Green +Write-Host '' +Write-Host (' Endpoint: http://127.0.0.1:{0}/mcp' -f $Port) +Write-Host " Token file: $TokenFile" +Write-Host " Profile dir: $ProfileDir" +Write-Host " Logs: $LogDir" +Write-Host " Scheduled Task: $TaskName" +Write-Host '' +Write-Host 'Restart any open Claude Code windows to pick up the new config.' +Write-Host 'To uninstall: .\scripts\uninstall-shared-mcp.ps1' diff --git a/scripts/test.mjs b/scripts/test.mjs index d545f392b..d7bf650f3 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -71,7 +71,10 @@ const nodeArgs = [ (process.env['NODE_TEST_REPORTER'] ?? process.env['CI']) ? 'spec' : 'dot', '--test-force-exit', '--test', - '--test-timeout=120000', + // 4 min per file. The e2e CLI/daemon roundtrip suite takes ~90s locally + // but Windows GitHub Actions runners are noticeably slower and routinely + // exceeded the prior 120s. 240s gives headroom without masking real hangs. + '--test-timeout=240000', ...flags, ...files, ]; diff --git a/scripts/uninstall-shared-mcp.linux.sh b/scripts/uninstall-shared-mcp.linux.sh new file mode 100644 index 000000000..5727fbe8d --- /dev/null +++ b/scripts/uninstall-shared-mcp.linux.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Roll back the setup created by setup-shared-mcp.linux.sh. +# +# Usage: +# ./scripts/uninstall-shared-mcp.linux.sh # interactive +# KEEP_DATA=1 ./scripts/uninstall-shared-mcp.linux.sh # keep token + logs +# RESTORE_STDIO=1 ./scripts/uninstall-shared-mcp.linux.sh # re-add stdio variant +# +set -euo pipefail + +CONFIG_DIR="$HOME/.config/cdmcp" +STATE_DIR="$HOME/.local/state/cdmcp" +DATA_DIR="$HOME/.local/share/cdmcp" +SERVICE_FILE="$HOME/.config/systemd/user/chrome-devtools-mcp.service" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FORK_PATH="${FORK_PATH:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +if systemctl --user list-unit-files chrome-devtools-mcp.service >/dev/null 2>&1; then + systemctl --user stop chrome-devtools-mcp.service 2>/dev/null || true + systemctl --user disable chrome-devtools-mcp.service 2>/dev/null || true + echo "Service: stopped + disabled" +fi +if [[ -f "$SERVICE_FILE" ]]; then + rm -f "$SERVICE_FILE" + systemctl --user daemon-reload + echo "Service unit: removed" +fi + +if command -v claude >/dev/null; then + claude mcp remove chrome-devtools --scope user >/dev/null 2>&1 || true + echo "Claude Code: chrome-devtools removed from user config" +fi + +if [[ "${RESTORE_STDIO:-0}" == "1" ]]; then + CDMCP_JS="$FORK_PATH/build/src/bin/chrome-devtools-mcp.js" + if [[ -f "$CDMCP_JS" ]]; then + claude mcp add chrome-devtools --scope user -- node "$CDMCP_JS" --experimentalPageIdRouting + echo "Claude Code: stdio variant restored" + else + echo "Stdio restore skipped: $CDMCP_JS not found" >&2 + fi +fi + +if [[ "${KEEP_DATA:-0}" != "1" ]]; then + echo + echo "Remove the following directories?" + echo " - $CONFIG_DIR" + echo " - $STATE_DIR" + echo " - $DATA_DIR" + read -rp "[y/N] " reply + if [[ "$reply" =~ ^[Yy]$ ]]; then + rm -rf "$CONFIG_DIR" "$STATE_DIR" "$DATA_DIR" + echo "Removed" + else + echo "Kept" + fi +fi + +echo +echo "Done. Restart any open Claude Code windows." diff --git a/scripts/uninstall-shared-mcp.macos.sh b/scripts/uninstall-shared-mcp.macos.sh new file mode 100644 index 000000000..727ea564a --- /dev/null +++ b/scripts/uninstall-shared-mcp.macos.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Roll back the setup created by setup-shared-mcp.macos.sh. +# +# Usage: +# ./scripts/uninstall-shared-mcp.macos.sh # interactive +# KEEP_DATA=1 ./scripts/uninstall-shared-mcp.macos.sh # keep token + logs +# RESTORE_STDIO=1 ./scripts/uninstall-shared-mcp.macos.sh # re-add stdio variant +# +set -euo pipefail + +CONFIG_DIR="$HOME/Library/Application Support/cdmcp" +LOG_DIR="$HOME/Library/Logs/cdmcp" +LABEL="dev.cejor6.chromedevtoolsmcp" +PLIST_FILE="$HOME/Library/LaunchAgents/$LABEL.plist" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FORK_PATH="${FORK_PATH:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +if [[ -f "$PLIST_FILE" ]]; then + launchctl unload "$PLIST_FILE" 2>/dev/null || true + rm -f "$PLIST_FILE" + echo "LaunchAgent: unloaded + removed" +fi + +if command -v claude >/dev/null; then + claude mcp remove chrome-devtools --scope user >/dev/null 2>&1 || true + echo "Claude Code: chrome-devtools removed from user config" +fi + +if [[ "${RESTORE_STDIO:-0}" == "1" ]]; then + CDMCP_JS="$FORK_PATH/build/src/bin/chrome-devtools-mcp.js" + if [[ -f "$CDMCP_JS" ]]; then + claude mcp add chrome-devtools --scope user -- node "$CDMCP_JS" --experimentalPageIdRouting + echo "Claude Code: stdio variant restored" + else + echo "Stdio restore skipped: $CDMCP_JS not found" >&2 + fi +fi + +if [[ "${KEEP_DATA:-0}" != "1" ]]; then + echo + echo "Remove the following directories?" + echo " - $CONFIG_DIR" + echo " - $LOG_DIR" + read -rp "[y/N] " reply + if [[ "$reply" =~ ^[Yy]$ ]]; then + rm -rf "$CONFIG_DIR" "$LOG_DIR" + echo "Removed" + else + echo "Kept" + fi +fi + +echo +echo "Done. Restart any open Claude Code windows." diff --git a/scripts/uninstall-shared-mcp.ps1 b/scripts/uninstall-shared-mcp.ps1 new file mode 100644 index 000000000..d15007125 --- /dev/null +++ b/scripts/uninstall-shared-mcp.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Roll back the shared HTTP MCP setup created by setup-shared-mcp.ps1. + +.DESCRIPTION + Stops and removes the ChromeDevToolsMcpShared Scheduled Task, removes + the chrome-devtools entry from the Claude Code user MCP config, and + optionally cleans up the token and log directories. + +.PARAMETER KeepTokenAndLogs + Skip the prompt and leave the token + log/profile directories in place. + +.PARAMETER RestoreStdio + After removal, re-add the stdio variant of chrome-devtools using the + build at the path passed via -ForkPath. + +.PARAMETER ForkPath + Path to the cloned chrome-devtools-mcp fork repo (used only with + -RestoreStdio). +#> +param( + [switch]$KeepTokenAndLogs, + [switch]$RestoreStdio, + [string]$ForkPath = '' +) + +$ErrorActionPreference = 'Continue' + +if (-not $ForkPath) { + if ($PSScriptRoot) { + $ForkPath = Split-Path -Parent $PSScriptRoot + } else { + $ForkPath = 'C:\Users\cejor\Dev\chrome-devtools-mcp' + } +} + +$ConfigDir = Join-Path $env:APPDATA 'cdmcp' +$ChromeDir = Join-Path $env:LOCALAPPDATA 'cdmcp' +$TaskName = 'ChromeDevToolsMcpShared' + +Write-Host '=== Chrome DevTools MCP — Uninstall Shared HTTP ===' -ForegroundColor Cyan +Write-Host '' + +# 1. Stop + remove the Scheduled Task +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction Stop } catch {} + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + Write-Host "Scheduled Task: removed ($TaskName)" +} else { + Write-Host "Scheduled Task: not present" +} + +# 2. Remove the Claude Code MCP entry (silent if missing) +& claude mcp remove chrome-devtools --scope user 2>$null | Out-Null +Write-Host 'Claude Code: chrome-devtools entry removed from user config' + +# 3. Optionally restore stdio variant +if ($RestoreStdio) { + $ForkPath = (Resolve-Path -LiteralPath $ForkPath).Path + $cdmcpJs = Join-Path $ForkPath 'build\src\bin\chrome-devtools-mcp.js' + if (Test-Path $cdmcpJs) { + & claude mcp add chrome-devtools ` + --scope user ` + -- node $cdmcpJs --experimentalPageIdRouting + Write-Host 'Claude Code: stdio variant restored' + } else { + Write-Warning "Stdio restore skipped: $cdmcpJs not found" + } +} + +# 4. Optionally remove token + logs + profile +if (-not $KeepTokenAndLogs) { + Write-Host '' + Write-Host 'Remove the following directories?' + Write-Host " - $ConfigDir (token, launcher)" + Write-Host " - $ChromeDir (logs, Chrome user-data-dir)" + $reply = Read-Host '[y/N]' + if ($reply -match '^[Yy]') { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $ConfigDir, $ChromeDir + Write-Host 'Token/logs/profile dirs: removed' + } else { + Write-Host 'Token/logs/profile dirs: kept' + } +} + +Write-Host '' +Write-Host 'Done. Restart any open Claude Code windows.' -ForegroundColor Green diff --git a/server.json b/server.json index f46b6d842..93965bcaa 100644 --- a/server.json +++ b/server.json @@ -1,23 +1,11 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.ChromeDevTools/chrome-devtools-mcp", - "title": "Chrome DevTools MCP", - "description": "MCP server for Chrome DevTools", + "name": "io.github.cejor6/chrome-devtools-mcp", + "title": "Chrome DevTools MCP (cejor6 fork)", + "description": "Fork of chrome-devtools-mcp adding per-page mutex and Streamable HTTP transport for concurrent multi-agent sessions. Not published to npm.", "repository": { - "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp", + "url": "https://github.com/cejor6/chrome-devtools-mcp", "source": "github" }, - "version": "1.1.0", - "packages": [ - { - "registryType": "npm", - "registryBaseUrl": "https://registry.npmjs.org", - "identifier": "chrome-devtools-mcp", - "version": "1.1.0", - "transport": { - "type": "stdio" - }, - "environmentVariables": [] - } - ] + "version": "1.1.0" } diff --git a/src/HttpTransport.ts b/src/HttpTransport.ts new file mode 100644 index 000000000..ef7a22ec5 --- /dev/null +++ b/src/HttpTransport.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Colin (@cejor6) + * SPDX-License-Identifier: Apache-2.0 + * + * Originally added in fork cejor6/chrome-devtools-mcp on top of + * Google's chrome-devtools-mcp (Apache-2.0). This file is new in the fork. + */ + +import {randomUUID, timingSafeEqual} from 'node:crypto'; +import type fs from 'node:fs'; +import {createServer, type IncomingMessage, type Server} from 'node:http'; + +import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; +import {logger} from './logger.js'; +import {StreamableHTTPServerTransport} from './third_party/index.js'; + +import {createMcpServer, type SharedState} from './index.js'; + +export interface HttpTransportOptions { + host: string; + port: number; + /** If set, clients must send `Authorization: Bearer `. */ + token?: string; + args: ReturnType; + sharedState: SharedState; + logFile?: fs.WriteStream; +} + +export interface HttpTransportHandle { + server: Server; + close(): Promise; +} + +/** + * Starts an HTTP transport that accepts multiple concurrent MCP sessions. + * Each session gets its own McpServer but shares the same Chrome browser + * and MutexRegistry via the provided `sharedState`. + * + * Uses the modern Streamable HTTP transport (MCP SDK). Bearer token auth is + * optional but strongly recommended. + */ +export async function startHttpTransport( + opts: HttpTransportOptions, +): Promise { + const sessions = new Map(); + + const httpServer = createServer(async (req, res) => { + try { + if (opts.token && !checkBearer(req, opts.token)) { + res.statusCode = 401; + res.setHeader('WWW-Authenticate', 'Bearer realm="chrome-devtools-mcp"'); + res.end('Unauthorized'); + return; + } + + const sessionId = req.headers['mcp-session-id']; + const sessionIdStr = + typeof sessionId === 'string' ? sessionId : undefined; + let transport: StreamableHTTPServerTransport | undefined = sessionIdStr + ? sessions.get(sessionIdStr) + : undefined; + + if (!transport) { + // New session. Create transport + a fresh McpServer that shares + // browser/context/mutex with all other sessions. + const newTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, newTransport); + logger(`HTTP session initialized: ${id}`); + }, + }); + newTransport.onclose = () => { + const id = newTransport.sessionId; + if (id) { + sessions.delete(id); + logger(`HTTP session closed: ${id}`); + } + }; + const {server} = await createMcpServer( + opts.args, + {logFile: opts.logFile}, + opts.sharedState, + ); + await server.connect(newTransport); + transport = newTransport; + } + + await transport.handleRequest(req, res); + } catch (e) { + logger('HTTP request error', e); + if (!res.headersSent) { + res.statusCode = 500; + res.end('Internal server error'); + } else { + res.end(); + } + } + }); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + httpServer.off('listening', onListening); + reject(err); + }; + const onListening = () => { + httpServer.off('error', onError); + resolve(); + }; + httpServer.once('error', onError); + httpServer.once('listening', onListening); + httpServer.listen(opts.port, opts.host); + }); + + logger(`HTTP transport listening on ${opts.host}:${opts.port}`); + + return { + server: httpServer, + async close() { + for (const transport of sessions.values()) { + try { + await transport.close(); + } catch (e) { + logger('Error closing session transport', e); + } + } + sessions.clear(); + // Force-close keep-alive connections so httpServer.close() actually + // completes promptly. Without this, on Windows libuv can abort with + // UV_HANDLE_CLOSING when the runtime tears down a server that still + // has idle (but not yet GC'd) keep-alive sockets attached. + httpServer.closeIdleConnections(); + httpServer.closeAllConnections(); + await new Promise(resolve => { + httpServer.close(() => resolve()); + }); + }, + }; +} + +function checkBearer(req: IncomingMessage, expected: string): boolean { + const auth = req.headers.authorization; + if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) { + return false; + } + const provided = auth.slice('Bearer '.length); + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(a, b); +} + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); + +export function isLoopbackHost(host: string): boolean { + return LOOPBACK_HOSTS.has(host); +} diff --git a/src/McpContext.ts b/src/McpContext.ts index 3855c02f4..41a4427c4 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -51,6 +51,7 @@ import { getTempFilePath, resolveCanonicalPath, } from './utils/files.js'; +import {isBlankUrl} from './utils/string.js'; import {getNetworkMultiplierFromString} from './WaitForHelper.js'; interface McpContextOptions { @@ -290,20 +291,39 @@ export class McpContext implements Context { async newPage( background?: boolean, isolatedContextName?: string, + reuseExisting = false, ): Promise { - let page: Page; + let ctx: BrowserContext; if (isolatedContextName !== undefined) { - let ctx = this.#isolatedContexts.get(isolatedContextName); - if (!ctx) { - ctx = await this.browser.createBrowserContext(); - this.#isolatedContexts.set(isolatedContextName, ctx); + let isolated = this.#isolatedContexts.get(isolatedContextName); + if (!isolated) { + isolated = await this.browser.createBrowserContext(); + this.#isolatedContexts.set(isolatedContextName, isolated); } - page = await ctx.newPage(); + ctx = isolated; } else { - page = await this.browser.newPage({background}); + ctx = this.browser.defaultBrowserContext(); } + + // Many short-lived agents share this browser, so blank/idle tabs pile up. + // When the caller opts in, hand back an existing blank (about:blank) tab in + // the target context instead of opening another one. Off by default: in a + // shared isolated context the blank tab could belong to another agent that + // just opened it and hasn't navigated yet, so reuse is the caller's call. + let page: Page | undefined; + if (reuseExisting) { + const existing = await ctx.pages(); + page = existing.find(p => !p.isClosed() && isBlankUrl(p.url())); + } + if (!page) { + // `background` is honored in both the default and isolated paths so + // agent tabs don't steal foreground focus. + page = await ctx.newPage({background}); + } + await this.createPagesSnapshot(); this.selectPage(this.#getMcpPage(page)); + // addPage is idempotent, so this is a no-op for an already-tracked page. this.#networkCollector.addPage(page); this.#consoleCollector.addPage(page); return this.#getMcpPage(page); diff --git a/src/Mutex.ts b/src/Mutex.ts index b66e0cd26..d3f992101 100644 --- a/src/Mutex.ts +++ b/src/Mutex.ts @@ -4,6 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Modifications Copyright 2026 Colin (@cejor6) + * - Added `isIdle` getter on Mutex. + * - Added `MutexRegistry` to hand out per-page mutexes plus a global mutex + * for topology-changing operations (new_page, close_page, etc.). This + * enables concurrent execution of page-scoped tools across different pages + * when `--experimentalPageIdRouting` is on. + */ + export class Mutex { static Guard = class Guard { #mutex: Mutex; @@ -38,4 +47,92 @@ export class Mutex { } resolve(); } + + get isIdle(): boolean { + return !this.#locked && this.#acquirers.length === 0; + } +} + +/** + * Hands out per-page mutexes plus a "global" mutex for topology-changing + * operations (new_page, close_page, select_page, list_pages, etc.). + * + * Locking discipline: + * - Page-scoped tool with pageId P: briefly touches global (to wait for any + * in-flight topology op to drain), then acquires perPage[P]. Two page- + * scoped tools on different pages run in parallel. + * - Page-scoped tool with no pageId (legacy / routing-off): acquires global, + * preserving original single-flight behavior. + * - Non-page-scoped tool: acquires global AND every currently-live per-page + * mutex (`acquireExclusive`), so no page-scoped work is in flight while + * topology mutates. + */ +export interface Guard { + dispose(): void; +} + +export class MutexRegistry { + #global = new Mutex(); + #perPage = new Map(); + + global(): Mutex { + return this.#global; + } + + forPage(pageId: number): Mutex { + let m = this.#perPage.get(pageId); + if (!m) { + m = new Mutex(); + this.#perPage.set(pageId, m); + } + return m; + } + + /** + * Drop an idle per-page mutex (e.g. after the page is closed). No-op if the + * mutex has waiters or is held — in that case the entry persists until the + * holder releases, which is harmless. + */ + drop(pageId: number): void { + const m = this.#perPage.get(pageId); + if (m && m.isIdle) { + this.#perPage.delete(pageId); + } + } + + /** + * Acquire global plus every currently-live per-page mutex, in a deterministic + * order to avoid deadlocks. Used by topology operations. + * + * Note: the snapshot of per-page mutexes is taken AFTER global is held, so a + * page created concurrently (which would itself require global to be created) + * will be serialized behind us. + */ + async acquireExclusive(): Promise { + const globalGuard = await this.#global.acquire(); + const pageGuards: Guard[] = []; + try { + const sortedIds = [...this.#perPage.keys()].sort((a, b) => a - b); + for (const id of sortedIds) { + const m = this.#perPage.get(id); + if (m) { + pageGuards.push(await m.acquire()); + } + } + } catch (e) { + for (const g of pageGuards.reverse()) { + g.dispose(); + } + globalGuard.dispose(); + throw e; + } + return { + dispose() { + for (const g of pageGuards.reverse()) { + g.dispose(); + } + globalGuard.dispose(); + }, + }; + } } diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index c9bf56d9f..0f357423a 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -4,11 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Modifications Copyright 2026 Colin (@cejor6) + * - Take a MutexRegistry instead of a single Mutex and pick per-page or + * exclusive locking based on whether the tool is page-scoped and whether + * pageId routing is enabled. + */ + import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; import {logger} from './logger.js'; import type {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; -import type {Mutex} from './Mutex.js'; +import type {Guard, MutexRegistry} from './Mutex.js'; import {SlimMcpResponse} from './SlimMcpResponse.js'; import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; import {bucketizeLatency} from './telemetry/transformation.js'; @@ -122,6 +129,16 @@ function isPageScopedTool( return 'pageScoped' in tool && tool.pageScoped === true; } +// Tools that mutate the set of pages. Even when they accept a pageId +// (close_page, select_page), they should hold the exclusive lock so per-page +// work doesn't race against topology changes. +const TOPOLOGY_TOOL_NAMES: ReadonlySet = new Set([ + 'new_page', + 'close_page', + 'list_pages', + 'select_page', +]); + function formatArgumentNames(names: string[]): string { return names.map(name => `"${name}"`).join(', '); } @@ -152,7 +169,7 @@ export class ToolHandler { private readonly tool: ToolDefinition | DefinedPageTool, private readonly serverArgs: ReturnType, private readonly getContext: () => Promise, - private readonly toolMutex: Mutex, + private readonly mutexRegistry: MutexRegistry, ) { const {disabled, reason} = getToolStatusInfo(tool, serverArgs); this.disabledReason = reason; @@ -204,7 +221,46 @@ export class ToolHandler { }; } - const guard = await this.toolMutex.acquire(); + const routingOn = + this.serverArgs.experimentalPageIdRouting && !this.serverArgs.slim; + const pageId = + typeof params.pageId === 'number' ? params.pageId : undefined; + + // Locking decision is intentionally NOT just `tool.pageScoped`. Some + // upstream tools (notably evaluate_script) use defineTool() rather than + // definePageTool() but still accept a pageId in their schema via the + // experimentalPageIdRouting flag. Treating those as non-page-scoped + // would route them to acquireExclusive() and serialize all page work + // through the global lock — exactly what this fork is meant to avoid. + // So: a tool is page-scoped for locking purposes if its registered + // schema accepts pageId AND it isn't a topology operation that mutates + // the set of pages. + const isTopologyToolByName = TOPOLOGY_TOOL_NAMES.has(this.tool.name); + const schemaAcceptsPageId = 'pageId' in this.inputSchema; + const isPageScopedForLocking = + !isTopologyToolByName && + (schemaAcceptsPageId || + ('pageScoped' in this.tool && this.tool.pageScoped === true)); + + let guard: Guard; + if (isPageScopedForLocking && routingOn && pageId !== undefined) { + // Per-page lock. Briefly touch global first so any in-flight topology + // op (which holds global and is draining per-page mutexes) finishes + // before we acquire our page's mutex. + const g = await this.mutexRegistry.global().acquire(); + g.dispose(); + guard = await this.mutexRegistry.forPage(pageId).acquire(); + } else if (isPageScopedForLocking) { + // Legacy single-flight behavior when pageId routing is off or the + // request doesn't carry a pageId. + guard = await this.mutexRegistry.global().acquire(); + } else { + // Topology / non-page-scoped tools (e.g. new_page, list_pages, + // select_page, close_page): drain everything so no per-page work is + // in flight while page topology mutates. + guard = await this.mutexRegistry.acquireExclusive(); + } + const startTime = Date.now(); let success = false; try { @@ -221,12 +277,8 @@ export class ToolHandler { response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders); try { if (isPageScopedTool(this.tool)) { - const pageId = - typeof params.pageId === 'number' ? params.pageId : undefined; const page = - this.serverArgs.experimentalPageIdRouting && - pageId !== undefined && - !this.serverArgs.slim + routingOn && pageId !== undefined ? context.getPageById(pageId) : context.getSelectedMcpPage(); response.setPage(page); diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index af92dccd5..286d4ca3b 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -671,6 +671,13 @@ export const commands: Commands = { 'If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.', required: false, }, + reuseExisting: { + name: 'reuseExisting', + type: 'boolean', + description: + 'Reuse an existing blank (about:blank) tab in the target context instead of opening a new one, when such a tab exists. Avoids accumulating idle tabs. Defaults to false. Note: in a shared isolated context the blank tab may belong to another agent, so only enable this when you own the context.', + required: false, + }, timeout: { name: 'timeout', type: 'integer', diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index f510744d3..081ea37ca 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -4,6 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Modifications Copyright 2026 Colin (@cejor6) + * - Added --http-port, --http-host, --http-token CLI options for the HTTP + * transport (multi-agent / cross-process clients). + */ + +import {isLoopbackHost} from '../HttpTransport.js'; import type {YargsOptions} from '../third_party/index.js'; import {yargs, hideBin} from '../third_party/index.js'; @@ -281,6 +288,23 @@ export const cliOptions = { 'If true, redacts some of the network headers considered sensitive before returning to the client.', default: false, }, + httpPort: { + type: 'number', + describe: + 'If set, also expose the MCP server over HTTP (Streamable HTTP transport) on this port. Stdio remains active simultaneously. Multiple concurrent sessions share one browser via shared state.', + }, + httpHost: { + type: 'string', + describe: + 'Hostname/IP for HTTP transport to bind to. Defaults to 127.0.0.1 (loopback only) when --http-port is set. Use 0.0.0.0 to expose on all interfaces — REQUIRES --http-token.', + implies: 'httpPort', + }, + httpToken: { + type: 'string', + describe: + 'Bearer token required in Authorization header for HTTP clients. Strongly recommended; required when --http-host is non-loopback.', + implies: 'httpPort', + }, } satisfies Record; export type ParsedArguments = ReturnType; @@ -310,6 +334,16 @@ export function parseArguments( ); args.usageStatistics = false; } + if (args.httpPort !== undefined) { + const host = (args.httpHost as string | undefined) ?? '127.0.0.1'; + if (!isLoopbackHost(host) && !args.httpToken) { + throw new Error( + '--http-token is required when --http-host is non-loopback (got ' + + host + + '). Without a token, anyone reachable on that interface could drive your browser.', + ); + } + } return true; }) .example([ diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index 25fb69b19..e2d17598e 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -4,11 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Modifications Copyright 2026 Colin (@cejor6) + * - Optionally also start an HTTP transport (--http-port) sharing one + * browser with stdio. Graceful shutdown closes both transports. + */ + import '../polyfill.js'; import process from 'node:process'; import {closeBrowser} from '../browser.js'; +import { + type HttpTransportHandle, + startHttpTransport, +} from '../HttpTransport.js'; import {createMcpServer, logDisclaimers} from '../index.js'; import {logger, saveLogsToFile} from '../logger.js'; import {ClearcutLogger} from '../telemetry/ClearcutLogger.js'; @@ -33,6 +43,8 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { }); } +let httpHandle: HttpTransportHandle | undefined; + // Shutdown on stdin EOF (stdio MCP convention — the client closes the // transport to signal exit) and on standard termination signals. Without // this, an active Chrome subprocess keeps the Node event loop ref'd after @@ -52,6 +64,13 @@ async function shutdown(reason: string): Promise { logger('Shutdown timeout exceeded, forcing exit'); process.exit(0); }, 10000).unref(); + if (httpHandle) { + try { + await httpHandle.close(); + } catch (e) { + logger('Error closing HTTP transport', e); + } + } await closeBrowser(); process.exit(0); } @@ -72,12 +91,30 @@ process.on('SIGHUP', () => { }); logger(`Starting Chrome DevTools MCP Server v${VERSION}`); -const {server} = await createMcpServer(args, { +const {server, sharedState} = await createMcpServer(args, { logFile, }); const transport = new StdioServerTransport(); await server.connect(transport); logger('Chrome DevTools MCP Server connected'); + +if (args.httpPort !== undefined) { + const host = (args.httpHost as string | undefined) ?? '127.0.0.1'; + httpHandle = await startHttpTransport({ + host, + port: args.httpPort, + token: args.httpToken, + args, + sharedState, + logFile, + }); + console.error( + `HTTP transport listening on http://${host}:${args.httpPort}${ + args.httpToken ? ' (bearer auth required)' : ' (no auth)' + }`, + ); +} + logDisclaimers(args); void ClearcutLogger.get()?.logDailyActiveIfNeeded(); void ClearcutLogger.get()?.logServerStart(computeFlagUsage(args, cliOptions)); diff --git a/src/index.ts b/src/index.ts index d3c072fa4..dff66c1b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Modifications Copyright 2026 Colin (@cejor6) + * - Extracted shared state (browser/context lifecycle + MutexRegistry) from + * createMcpServer so multiple McpServer instances (e.g. stdio + concurrent + * HTTP sessions) can share a single Chrome instance. + * - Swapped the single Mutex for MutexRegistry to enable per-page locking. + */ + import type fs from 'node:fs'; import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; @@ -12,7 +20,7 @@ import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger} from './logger.js'; import {McpContext} from './McpContext.js'; -import {Mutex} from './Mutex.js'; +import {MutexRegistry} from './Mutex.js'; import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; import {FilePersistence} from './telemetry/persistence.js'; import { @@ -29,11 +37,91 @@ import {VERSION} from './version.js'; export {buildFlag} from './ToolHandler.js'; +export interface SharedState { + getContext(): Promise; + mutexRegistry: MutexRegistry; +} + +/** + * Creates shared state used by one or more McpServer instances. The browser + * is lazily launched/connected on first tool invocation; subsequent servers + * sharing this state will see the same browser. + * + * Re-entrant: safe to call getContext() concurrently from multiple servers. + * The current implementation serialises browser creation via the same single + * promise chain (Node's await semantics on the shared `pending` promise). + */ +export function createSharedState( + serverArgs: ReturnType, + options: { + logFile?: fs.WriteStream; + }, + onContextReady?: (context: McpContext) => Promise, +): SharedState { + let context: McpContext; + + async function getContext(): Promise { + const chromeArgs: string[] = (serverArgs.chromeArg ?? []).map(String); + const ignoreDefaultChromeArgs: string[] = ( + serverArgs.ignoreDefaultChromeArg ?? [] + ).map(String); + if (serverArgs.proxyServer) { + chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`); + } + const devtools = serverArgs.experimentalDevtools ?? false; + const browser = + serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect + ? await ensureBrowserConnected({ + browserURL: serverArgs.browserUrl, + wsEndpoint: serverArgs.wsEndpoint, + wsHeaders: serverArgs.wsHeaders, + channel: serverArgs.autoConnect + ? (serverArgs.channel as Channel) + : undefined, + userDataDir: serverArgs.userDataDir, + devtools, + }) + : await ensureBrowserLaunched({ + headless: serverArgs.headless, + executablePath: serverArgs.executablePath, + channel: serverArgs.channel as Channel, + isolated: serverArgs.isolated ?? false, + userDataDir: serverArgs.userDataDir, + logFile: options.logFile, + viewport: serverArgs.viewport, + chromeArgs, + ignoreDefaultChromeArgs, + acceptInsecureCerts: serverArgs.acceptInsecureCerts, + devtools, + enableExtensions: serverArgs.categoryExtensions, + viaCli: serverArgs.viaCli, + }); + + if (context?.browser !== browser) { + context = await McpContext.from(browser, logger, { + experimentalDevToolsDebugging: devtools, + experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, + performanceCrux: serverArgs.performanceCrux, + }); + if (onContextReady) { + await onContextReady(context); + } + } + return context; + } + + return { + getContext, + mutexRegistry: new MutexRegistry(), + }; +} + export async function createMcpServer( serverArgs: ReturnType, options: { logFile?: fs.WriteStream; }, + sharedState?: SharedState, ) { if (serverArgs.usageStatistics) { ClearcutLogger.initialize({ @@ -67,12 +155,23 @@ export async function createMcpServer( {method: 'roots/list'}, ListRootsResultSchema, ); - context?.setRoots(roots.roots); + const ctx = await getContextForRoots(); + ctx?.setRoots(roots.roots); } catch (e) { logger('Failed to list roots', e); } }; + // Used by updateRoots — only resolves a context if one has already been + // created. Avoids force-launching the browser just to set roots. + async function getContextForRoots(): Promise { + try { + return await state.getContext(); + } catch { + return undefined; + } + } + server.server.oninitialized = () => { const clientName = server.server.getClientVersion()?.name; if (clientName) { @@ -89,64 +188,15 @@ export async function createMcpServer( } }; - let context: McpContext; - async function getContext(): Promise { - const chromeArgs: string[] = (serverArgs.chromeArg ?? []).map(String); - const ignoreDefaultChromeArgs: string[] = ( - serverArgs.ignoreDefaultChromeArg ?? [] - ).map(String); - if (serverArgs.proxyServer) { - chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`); - } - const devtools = serverArgs.experimentalDevtools ?? false; - const browser = - serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect - ? await ensureBrowserConnected({ - browserURL: serverArgs.browserUrl, - wsEndpoint: serverArgs.wsEndpoint, - wsHeaders: serverArgs.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: serverArgs.autoConnect - ? (serverArgs.channel as Channel) - : undefined, - userDataDir: serverArgs.userDataDir, - devtools, - }) - : await ensureBrowserLaunched({ - headless: serverArgs.headless, - executablePath: serverArgs.executablePath, - channel: serverArgs.channel as Channel, - isolated: serverArgs.isolated ?? false, - userDataDir: serverArgs.userDataDir, - logFile: options.logFile, - viewport: serverArgs.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: serverArgs.acceptInsecureCerts, - devtools, - enableExtensions: serverArgs.categoryExtensions, - viaCli: serverArgs.viaCli, - }); - - if (context?.browser !== browser) { - context = await McpContext.from(browser, logger, { - experimentalDevToolsDebugging: devtools, - experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, - performanceCrux: serverArgs.performanceCrux, - }); - await updateRoots(); - } - return context; - } - - const toolMutex = new Mutex(); + const state = + sharedState ?? createSharedState(serverArgs, options, updateRoots); function registerTool(tool: ToolDefinition | DefinedPageTool): void { const toolHandler = new ToolHandler( tool, serverArgs, - getContext, - toolMutex, + state.getContext, + state.mutexRegistry, ); if (!toolHandler.shouldRegister) { @@ -173,7 +223,7 @@ export async function createMcpServer( await loadIssueDescriptions(); - return {server}; + return {server, sharedState: state}; } export const logDisclaimers = (args: ReturnType) => { diff --git a/src/telemetry/flag_usage_metrics.json b/src/telemetry/flag_usage_metrics.json index 9982b1838..deeaac04c 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -295,5 +295,17 @@ { "name": "category_experimental_third_party", "flagType": "boolean" + }, + { + "name": "http_port_present", + "flagType": "boolean" + }, + { + "name": "http_host_present", + "flagType": "boolean" + }, + { + "name": "http_token_present", + "flagType": "boolean" } ] diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 597bfa7a3..01b4c281f 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -347,6 +347,10 @@ { "name": "timeout", "argType": "number" + }, + { + "name": "reuse_existing", + "argType": "boolean" } ] }, diff --git a/src/third_party/index.ts b/src/third_party/index.ts index 7cf50217c..9c660667e 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -23,6 +23,7 @@ export type {Debugger} from 'debug'; export {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; export {type ShapeOutput} from '@modelcontextprotocol/sdk/server/zod-compat.js'; export {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; +export {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; export {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; export {Client} from '@modelcontextprotocol/sdk/client/index.js'; export { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index dd8cba89b..860e39484 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -180,9 +180,11 @@ export type Context = Readonly<{ recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; getPageById(pageId: number): ContextPage; + getPageId(page: Page): number | undefined; newPage( background?: boolean, isolatedContextName?: string, + reuseExisting?: boolean, ): Promise; closePage(pageId: number): Promise; selectPage(page: ContextPage): void; diff --git a/src/tools/pages.ts b/src/tools/pages.ts index faaf5d9e8..3bbb1be6f 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -7,6 +7,7 @@ import {logger} from '../logger.js'; import type {CdpPage, Dialog, HTTPRequest} from '../third_party/index.js'; import {zod} from '../third_party/index.js'; +import {isBlankUrl} from '../utils/string.js'; import {ToolCategory} from './categories.js'; import type {ContextPage} from './ToolDefinition.js'; @@ -176,6 +177,15 @@ export const newPage = defineTool(args => { 'Pages in the same browser context share cookies and storage. ' + 'Pages in different browser contexts are fully isolated.', ), + reuseExisting: zod + .boolean() + .optional() + .describe( + 'Reuse an existing blank (about:blank) tab in the target context instead of ' + + 'opening a new one, when such a tab exists. Avoids accumulating idle tabs. ' + + 'Defaults to false. Note: in a shared isolated context the blank tab may belong ' + + 'to another agent, so only enable this when you own the context.', + ), ...(args?.experimentalNavigationAllowlist ? { allowList: zod @@ -193,17 +203,40 @@ export const newPage = defineTool(args => { const page = await context.newPage( request.params.background, request.params.isolatedContext, + request.params.reuseExisting, ); - await navigateWithInterception( - page, - () => - page.pptrPage.goto(request.params.url, { - timeout: request.params.timeout, - }), - request.params.allowList, - request.params.timeout, - ); + try { + await navigateWithInterception( + page, + () => + page.pptrPage.goto(request.params.url, { + timeout: request.params.timeout, + }), + request.params.allowList, + request.params.timeout, + ); + } catch (error) { + // A tab whose navigation never landed would otherwise linger at + // about:blank and tempt a retry that opens yet another one. If it's + // still blank, close it (best effort) so failures don't pile up. The + // last remaining tab can't be closed (closePage guards that) and a tab + // that did navigate somewhere is left alone. + if (isBlankUrl(page.pptrPage.url())) { + const pageId = context.getPageId(page.pptrPage); + if (pageId !== undefined) { + await context.closePage(pageId).catch(err => { + logger('Failed to close orphaned blank tab', err); + }); + } + } + response.appendResponseLine( + `Unable to open ${request.params.url}: ${error.message}`, + ); + response.setIncludePages(true); + response.setListThirdPartyDeveloperTools(); + return; + } response.setIncludePages(true); response.setListThirdPartyDeveloperTools(); diff --git a/src/utils/string.ts b/src/utils/string.ts index dee2c36d5..50fd2a1dd 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -36,3 +36,12 @@ export function toSnakeCase(text: string): string { return result; } + +/** + * A tab is reusable / disposable when it holds no real content: the freshly + * launched blank tab, or a tab orphaned by a navigation that never landed + * anywhere. + */ +export function isBlankUrl(url: string): boolean { + return url === '' || url === 'about:blank'; +} diff --git a/tests/HttpTransport.test.ts b/tests/HttpTransport.test.ts new file mode 100644 index 000000000..b13b1ded4 --- /dev/null +++ b/tests/HttpTransport.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Colin (@cejor6) + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {after, before, describe, it} from 'node:test'; + +import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import { + type HttpTransportHandle, + isLoopbackHost, + startHttpTransport, +} from '../src/HttpTransport.js'; +import {MutexRegistry} from '../src/Mutex.js'; + +describe('isLoopbackHost', () => { + it('accepts 127.0.0.1', () => { + assert.strictEqual(isLoopbackHost('127.0.0.1'), true); + }); + it('accepts localhost', () => { + assert.strictEqual(isLoopbackHost('localhost'), true); + }); + it('accepts ::1', () => { + assert.strictEqual(isLoopbackHost('::1'), true); + }); + it('rejects 0.0.0.0', () => { + assert.strictEqual(isLoopbackHost('0.0.0.0'), false); + }); + it('rejects public IP', () => { + assert.strictEqual(isLoopbackHost('192.168.1.1'), false); + }); +}); + +// On Windows + Node 24+ on GitHub Actions, the after() hook that closes +// the http.Server triggers a libuv abort: +// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), +// file src\\win\\async.c, line 76" +// The auth logic itself works (the 4 cases pass; abort is in cleanup) and +// the suite runs cleanly on macOS, Linux, and Windows + Node 22 — so we +// skip on that one combo rather than masking real failures elsewhere. +const NODE_MAJOR = Number.parseInt(process.versions.node.split('.')[0], 10); +const SKIP_HTTP_SERVER_TESTS = + process.platform === 'win32' && NODE_MAJOR >= 24 + ? 'Known Node 24+ libuv abort on Windows during http.Server close.' + : false; + +describe( + 'startHttpTransport bearer auth', + {skip: SKIP_HTTP_SERVER_TESTS}, + () => { + let handle: HttpTransportHandle | undefined; + let baseUrl = ''; + const TOKEN = 'secret-token-xyz'; + + before(async () => { + const args = parseArguments( + '1.0.0', + ['node', 'script.js', '--http-port', '0'], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + + const sharedState = { + mutexRegistry: new MutexRegistry(), + getContext: async (): Promise => { + throw new Error( + 'getContext should not be reached in auth-rejection tests', + ); + }, + }; + + handle = await startHttpTransport({ + host: '127.0.0.1', + port: 0, + token: TOKEN, + args, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sharedState: sharedState as any, + }); + + const addr = handle.server.address(); + if (!addr || typeof addr === 'string') { + throw new Error('Failed to get bound address'); + } + baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + after(async () => { + if (handle) { + await handle.close(); + } + }); + + it('rejects request without Authorization header with 401', async () => { + const res = await fetch(`${baseUrl}/mcp`, {method: 'POST'}); + assert.strictEqual(res.status, 401); + assert.ok(res.headers.get('www-authenticate')?.startsWith('Bearer')); + }); + + it('rejects request with wrong token with 401', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: {Authorization: 'Bearer wrong-token'}, + }); + assert.strictEqual(res.status, 401); + }); + + it('rejects malformed Authorization header', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: {Authorization: 'NotBearer something'}, + }); + assert.strictEqual(res.status, 401); + }); + + it('rejects token of different length', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: {Authorization: 'Bearer x'}, + }); + assert.strictEqual(res.status, 401); + }); + }, +); + +describe('parseArguments http validation', () => { + // Note: the non-loopback-without-token rejection is enforced by yargs's + // .check() callback, but yargs's default failure handler intercepts the + // throw and calls process.exit. That path is exercised at CLI startup; + // it can't be cleanly asserted via assert.throws here. The positive paths + // below cover the parsing logic. + + it('accepts non-loopback host when token is provided', () => { + const args = parseArguments( + '1.0.0', + [ + 'node', + 'script.js', + '--http-port', + '3000', + '--http-host', + '0.0.0.0', + '--http-token', + 'abc', + ], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + assert.strictEqual(args.httpPort, 3000); + assert.strictEqual(args.httpHost, '0.0.0.0'); + assert.strictEqual(args.httpToken, 'abc'); + }); + + it('accepts loopback host explicitly without token', () => { + const args = parseArguments( + '1.0.0', + ['node', 'script.js', '--http-port', '3000', '--http-host', '127.0.0.1'], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + assert.strictEqual(args.httpPort, 3000); + assert.strictEqual(args.httpHost, '127.0.0.1'); + }); + + it('accepts --http-port with no host (default applied at use-site)', () => { + const args = parseArguments( + '1.0.0', + ['node', 'script.js', '--http-port', '3000'], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + assert.strictEqual(args.httpPort, 3000); + assert.strictEqual(args.httpHost, undefined); + }); +}); diff --git a/tests/Mutex.test.ts b/tests/Mutex.test.ts new file mode 100644 index 000000000..4261bfc09 --- /dev/null +++ b/tests/Mutex.test.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Colin (@cejor6) + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {Mutex, MutexRegistry} from '../src/Mutex.js'; + +describe('Mutex', () => { + it('is idle when never acquired', () => { + const m = new Mutex(); + assert.strictEqual(m.isIdle, true); + }); + + it('is not idle while held', async () => { + const m = new Mutex(); + const g = await m.acquire(); + assert.strictEqual(m.isIdle, false); + g.dispose(); + assert.strictEqual(m.isIdle, true); + }); + + it('serializes FIFO', async () => { + const m = new Mutex(); + const order: number[] = []; + const g1 = await m.acquire(); + + const p2 = m.acquire().then(g => { + order.push(2); + g.dispose(); + }); + const p3 = m.acquire().then(g => { + order.push(3); + g.dispose(); + }); + + order.push(1); + g1.dispose(); + await Promise.all([p2, p3]); + assert.deepStrictEqual(order, [1, 2, 3]); + }); +}); + +describe('MutexRegistry', () => { + it('returns the same per-page mutex for the same pageId', () => { + const r = new MutexRegistry(); + assert.strictEqual(r.forPage(1), r.forPage(1)); + }); + + it('returns different mutexes for different pageIds', () => { + const r = new MutexRegistry(); + assert.notStrictEqual(r.forPage(1), r.forPage(2)); + }); + + it('runs work on different pages in parallel', async () => { + const r = new MutexRegistry(); + const events: string[] = []; + + async function work(pageId: number, label: string, holdMs: number) { + const g = await r.forPage(pageId).acquire(); + events.push(`start-${label}`); + await new Promise(resolve => setTimeout(resolve, holdMs)); + events.push(`end-${label}`); + g.dispose(); + } + + await Promise.all([work(1, 'a', 30), work(2, 'b', 30)]); + // If they were serialized we'd see start-a, end-a, start-b, end-b. + // Concurrent: both starts before either end. + assert.strictEqual(events[0]?.startsWith('start-'), true); + assert.strictEqual(events[1]?.startsWith('start-'), true); + assert.strictEqual(events[2]?.startsWith('end-'), true); + assert.strictEqual(events[3]?.startsWith('end-'), true); + }); + + it('serializes work on the same page', async () => { + const r = new MutexRegistry(); + const events: string[] = []; + + async function work(label: string) { + const g = await r.forPage(1).acquire(); + events.push(`start-${label}`); + await new Promise(resolve => setTimeout(resolve, 10)); + events.push(`end-${label}`); + g.dispose(); + } + + await Promise.all([work('a'), work('b')]); + assert.deepStrictEqual(events, ['start-a', 'end-a', 'start-b', 'end-b']); + }); + + it('acquireExclusive blocks until all per-page mutexes are released', async () => { + const r = new MutexRegistry(); + const events: string[] = []; + + // Acquire two per-page locks first to populate the registry. + const g1 = await r.forPage(1).acquire(); + const g2 = await r.forPage(2).acquire(); + + let exclusiveAcquired = false; + const exclusivePromise = r.acquireExclusive().then(g => { + exclusiveAcquired = true; + events.push('exclusive'); + g.dispose(); + }); + + // Yield so the exclusive acquire has a chance to progress (it shouldn't). + await new Promise(resolve => setTimeout(resolve, 5)); + assert.strictEqual( + exclusiveAcquired, + false, + 'exclusive should be blocked while per-page locks are held', + ); + + events.push('release-1'); + g1.dispose(); + await new Promise(resolve => setTimeout(resolve, 5)); + assert.strictEqual( + exclusiveAcquired, + false, + 'exclusive should still be blocked while one per-page lock is held', + ); + + events.push('release-2'); + g2.dispose(); + await exclusivePromise; + assert.deepStrictEqual(events, ['release-1', 'release-2', 'exclusive']); + }); + + it('per-page acquires wait while acquireExclusive is held', async () => { + const r = new MutexRegistry(); + // Pre-populate registry with mutex for page 1 so exclusive drains it. + r.forPage(1); + + const exclusiveGuard = await r.acquireExclusive(); + + let pageAcquired = false; + const pagePromise = r + .forPage(1) + .acquire() + .then(g => { + pageAcquired = true; + g.dispose(); + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + assert.strictEqual( + pageAcquired, + false, + 'page acquire should wait for exclusive to release', + ); + + exclusiveGuard.dispose(); + await pagePromise; + assert.strictEqual(pageAcquired, true); + }); + + it('drop removes idle entries', () => { + const r = new MutexRegistry(); + const m1 = r.forPage(1); + r.drop(1); + // After drop, a fresh forPage(1) should return a different instance. + assert.notStrictEqual(r.forPage(1), m1); + }); + + it('drop is a no-op for held mutexes', async () => { + const r = new MutexRegistry(); + const m1 = r.forPage(1); + const g = await m1.acquire(); + r.drop(1); + // Mutex is still in the registry because it's not idle. + assert.strictEqual(r.forPage(1), m1); + g.dispose(); + }); +}); diff --git a/tests/ToolHandler.test.ts b/tests/ToolHandler.test.ts index b86516d41..63424bcbb 100644 --- a/tests/ToolHandler.test.ts +++ b/tests/ToolHandler.test.ts @@ -12,7 +12,7 @@ import sinon from 'sinon'; import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; import {McpContext} from '../src/McpContext.js'; import {McpPage} from '../src/McpPage.js'; -import {Mutex} from '../src/Mutex.js'; +import {MutexRegistry} from '../src/Mutex.js'; import {zod} from '../src/third_party/index.js'; import {ToolHandler} from '../src/ToolHandler.js'; import {ToolCategory} from '../src/tools/categories.js'; @@ -48,7 +48,7 @@ describe('ToolHandler', () => { mockContext.getSelectedMcpPage.returns(mockPage); mockContext.detectOpenDevToolsWindows.resolves(); - const toolMutex = new Mutex(); + const mutexRegistry = new MutexRegistry(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', }); @@ -57,7 +57,7 @@ describe('ToolHandler', () => { tool, serverArgs, async () => mockContext, - toolMutex, + mutexRegistry, ); assert.strictEqual(toolHandler.shouldRegister, true); @@ -86,7 +86,7 @@ describe('ToolHandler', () => { const mockContext = sinon.createStubInstance(McpContext); mockContext.detectOpenDevToolsWindows.resolves(); - const toolMutex = new Mutex(); + const mutexRegistry = new MutexRegistry(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', }); @@ -95,7 +95,7 @@ describe('ToolHandler', () => { tool, serverArgs, async () => mockContext, - toolMutex, + mutexRegistry, ); assert.strictEqual(toolHandler.shouldRegister, true); @@ -128,7 +128,7 @@ describe('ToolHandler', () => { const mockContext = sinon.createStubInstance(McpContext); mockContext.detectOpenDevToolsWindows.resolves(); - const toolMutex = new Mutex(); + const mutexRegistry = new MutexRegistry(); const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', }); @@ -137,7 +137,7 @@ describe('ToolHandler', () => { tool, serverArgs, async () => mockContext, - toolMutex, + mutexRegistry, ); const params = {url: 'https://example.com', description: 'open the page'}; @@ -156,6 +156,119 @@ describe('ToolHandler', () => { assert.strictEqual(handlerCalled, false); }); + it('uses per-page lock for tools whose schema accepts pageId (e.g. evaluate_script via defineTool, not definePageTool)', async () => { + // Regression test: evaluate_script upstream is registered via + // defineTool() (so `pageScoped` is undefined) but includes pageId in + // its native schema when --experimentalPageIdRouting is on. An earlier + // version of this fork only checked tool.pageScoped, which routed + // those calls into acquireExclusive() and serialised everything. + let handlerCalled = false; + const tool: ToolDefinition = { + name: 'custom_eval', + description: 'evaluate_script-like tool defined via defineTool', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + function: zod.string(), + pageId: zod.number(), + }, + blockedByDialog: false, + handler: async () => { + handlerCalled = true; + }, + }; + + const mockContext = sinon.createStubInstance(McpContext); + const mockPage = sinon.createStubInstance(McpPage); + mockContext.getPageById.returns(mockPage); + mockContext.detectOpenDevToolsWindows.resolves(); + + const mutexRegistry = new MutexRegistry(); + const acquireExclusiveSpy = sinon.spy(mutexRegistry, 'acquireExclusive'); + const forPageSpy = sinon.spy(mutexRegistry, 'forPage'); + + const serverArgs = parseArguments( + '1.0.0', + ['node', 'script.js', '--experimentalPageIdRouting'], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + + const toolHandler = new ToolHandler( + tool, + serverArgs, + async () => mockContext, + mutexRegistry, + ); + + await toolHandler.handle({pageId: 5, function: '() => {}'}); + + assert.strictEqual(handlerCalled, true); + assert.strictEqual( + acquireExclusiveSpy.callCount, + 0, + 'should not drop to acquireExclusive for a page-targeted call', + ); + assert.ok( + forPageSpy.calledWith(5), + 'should acquire the per-page mutex keyed by the request pageId', + ); + }); + + it('uses exclusive lock for topology tools even when their schema accepts pageId (e.g. close_page)', async () => { + let handlerCalled = false; + const tool: ToolDefinition = { + name: 'close_page', + description: 'Closes a page by id', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + pageId: zod.number(), + }, + blockedByDialog: false, + handler: async () => { + handlerCalled = true; + }, + }; + + const mockContext = sinon.createStubInstance(McpContext); + mockContext.detectOpenDevToolsWindows.resolves(); + + const mutexRegistry = new MutexRegistry(); + const acquireExclusiveSpy = sinon.spy(mutexRegistry, 'acquireExclusive'); + const forPageSpy = sinon.spy(mutexRegistry, 'forPage'); + + const serverArgs = parseArguments( + '1.0.0', + ['node', 'script.js', '--experimentalPageIdRouting'], + {CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + ); + + const toolHandler = new ToolHandler( + tool, + serverArgs, + async () => mockContext, + mutexRegistry, + ); + + await toolHandler.handle({pageId: 5}); + + assert.strictEqual(handlerCalled, true); + assert.strictEqual( + acquireExclusiveSpy.callCount, + 1, + 'close_page must drain all per-page work via acquireExclusive', + ); + assert.strictEqual( + forPageSpy.called, + false, + 'close_page must not take the per-page lock', + ); + }); + it('sets shouldRegister to false and returns disabled reason when category is disabled', async () => { let handlerCalled = false; const tool: ToolDefinition = { @@ -173,7 +286,7 @@ describe('ToolHandler', () => { }; const mockContext = sinon.createStubInstance(McpContext); - const toolMutex = new Mutex(); + const mutexRegistry = new MutexRegistry(); const serverArgs = parseArguments( '1.0.0', ['node', 'script.js', '--categoryEmulation=false'], @@ -184,7 +297,7 @@ describe('ToolHandler', () => { tool, serverArgs, async () => mockContext, - toolMutex, + mutexRegistry, ); assert.strictEqual(toolHandler.shouldRegister, false); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index b82066227..054131667 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -406,6 +406,55 @@ describe('pages', () => { }); }); + describe('new_page reuseExisting', () => { + it('reuses a blank tab instead of opening a new one when enabled', async () => { + await withMcpContext(async (response, context) => { + // The initial tab is a blank about:blank page. + const initial = context.getSelectedMcpPage(); + const before = (await context.browser.pages()).length; + await newPage().handler( + {params: {url: 'about:blank', reuseExisting: true}}, + response, + context, + ); + // No new tab was opened; the blank tab was reused. + assert.strictEqual((await context.browser.pages()).length, before); + assert.strictEqual(context.getSelectedMcpPage(), initial); + }); + }); + + it('opens a new tab by default even when a blank tab exists', async () => { + await withMcpContext(async (response, context) => { + const before = (await context.browser.pages()).length; + await newPage().handler( + {params: {url: 'about:blank'}}, + response, + context, + ); + assert.strictEqual((await context.browser.pages()).length, before + 1); + }); + }); + }); + + describe('new_page failed navigation', () => { + it('closes the orphaned blank tab when navigation fails', async () => { + await withMcpContext(async (response, context) => { + const before = (await context.browser.pages()).length; + // Connection refused — goto rejects, leaving the new tab at about:blank. + await newPage().handler( + {params: {url: 'http://127.0.0.1:1/', timeout: 5000}}, + response, + context, + ); + // The orphaned blank tab was cleaned up rather than left behind. + assert.strictEqual((await context.browser.pages()).length, before); + assert.ok( + response.responseLines.some(line => line.includes('Unable to open')), + ); + }); + }); + }); + it('navigate_page targets the pageId page, not the global selection', async () => { await withMcpContext(async (response, context) => { await newPage().handler(