Skip to content

Commit 3779d89

Browse files
committed
feat: add switch_browser tool with noLaunch CLI option
Adds the ability to dynamically switch between browser instances at runtime, with support for both HTTP and WebSocket connection URLs and configurable connection timeouts. New Features: - switch_browser tool for connecting to different browser instances - --noLaunch CLI flag to start without auto-launching a browser - Timeout parameter with 10-second default for connection attempts - Automatic HTTP to WebSocket URL conversion - Support for ws://, wss://, http://, and https:// protocols Implementation: - Created src/context.ts to manage browser context lifecycle - Created src/config.ts to break dependency cycles - Added getBrowser/setBrowser helpers for better testability - Added disconnectBrowser() to safely close browser connections - Proper timeout cleanup to prevent resource leaks - Comprehensive error handling with proper Error type checks Testing: - 6 new integration tests with actual browser instances - Tests verify browser disconnection, connection switching, and error handling - All tests use headless mode for CI compatibility - Proper test isolation with independent browser instances per test Documentation: - Updated README.md with new tool and CLI option - Auto-generated tool reference documentation - Updated release-please configuration
1 parent c3784d1 commit 3779d89

File tree

13 files changed

+775
-42
lines changed

13 files changed

+775
-42
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,13 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
263263
- [`hover`](docs/tool-reference.md#hover)
264264
- [`press_key`](docs/tool-reference.md#press_key)
265265
- [`upload_file`](docs/tool-reference.md#upload_file)
266-
- **Navigation automation** (6 tools)
266+
- **Navigation automation** (7 tools)
267267
- [`close_page`](docs/tool-reference.md#close_page)
268268
- [`list_pages`](docs/tool-reference.md#list_pages)
269269
- [`navigate_page`](docs/tool-reference.md#navigate_page)
270270
- [`new_page`](docs/tool-reference.md#new_page)
271271
- [`select_page`](docs/tool-reference.md#select_page)
272+
- [`switch_browser`](docs/tool-reference.md#switch_browser)
272273
- [`wait_for`](docs/tool-reference.md#wait_for)
273274
- **Emulation** (2 tools)
274275
- [`emulate`](docs/tool-reference.md#emulate)
@@ -361,6 +362,11 @@ The Chrome DevTools MCP server supports the following configuration option:
361362
- **Type:** boolean
362363
- **Default:** `true`
363364

365+
- **`--noLaunch`**
366+
Do not launch or connect to a browser automatically. Use switch_browser tool to connect manually.
367+
- **Type:** boolean
368+
- **Default:** `false`
369+
364370
<!-- END AUTO GENERATED OPTIONS -->
365371

366372
Pass them via the `args` property in the JSON configuration. For example:

docs/tool-reference.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
- [`hover`](#hover)
1212
- [`press_key`](#press_key)
1313
- [`upload_file`](#upload_file)
14-
- **[Navigation automation](#navigation-automation)** (6 tools)
14+
- **[Navigation automation](#navigation-automation)** (7 tools)
1515
- [`close_page`](#close_page)
1616
- [`list_pages`](#list_pages)
1717
- [`navigate_page`](#navigate_page)
1818
- [`new_page`](#new_page)
1919
- [`select_page`](#select_page)
20+
- [`switch_browser`](#switch_browser)
2021
- [`wait_for`](#wait_for)
2122
- **[Emulation](#emulation)** (2 tools)
2223
- [`emulate`](#emulate)
@@ -176,6 +177,16 @@
176177

177178
---
178179

180+
### `switch_browser`
181+
182+
**Description:** Connect to a different browser instance. Disconnects from the current browser (if any) and establishes a new connection. Accepts either HTTP URLs (e.g., http://127.0.0.1:9222) or WebSocket endpoints (e.g., ws://127.0.0.1:9222/devtools/browser/&lt;id&gt;).
183+
184+
**Parameters:**
185+
186+
- **url** (string) **(required)**: Browser connection URL. Can be an HTTP URL (e.g., http://127.0.0.1:9222) which will be auto-converted to WebSocket, or a direct WebSocket endpoint (e.g., ws://127.0.0.1:52862/devtools/browser/&lt;id&gt;).
187+
188+
---
189+
179190
### `wait_for`
180191

181192
**Description:** Wait for the specified text to appear on the selected page.

release-please-config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"extra-files": [
1919
{
2020
"type": "generic",
21-
"path": "src/main.ts"
21+
"path": "src/config.ts"
2222
},
2323
{
2424
"type": "json",

src/McpContext.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,9 @@ export class McpContext implements Context {
399399
});
400400

401401
if (!this.#selectedPage || this.#pages.indexOf(this.#selectedPage) === -1) {
402-
this.selectPage(this.#pages[0]);
402+
if (this.#pages.length > 0) {
403+
this.selectPage(this.#pages[0]);
404+
}
403405
}
404406

405407
await this.detectOpenDevToolsWindows();

src/browser.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ import {puppeteer} from './third_party/index.js';
1919

2020
let browser: Browser | undefined;
2121

22+
export async function disconnectBrowser(): Promise<void> {
23+
if (browser?.connected) {
24+
await browser.close();
25+
browser = undefined;
26+
}
27+
}
28+
29+
export function getBrowser(): Browser | undefined {
30+
return browser;
31+
}
32+
33+
export function setBrowser(newBrowser: Browser | undefined): void {
34+
browser = newBrowser;
35+
}
36+
2237
function makeTargetFilter() {
2338
const ignoredPrefixes = new Set([
2439
'chrome://',
@@ -67,9 +82,17 @@ export async function ensureBrowserConnected(options: {
6782
}
6883

6984
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
70-
browser = await puppeteer.connect(connectOptions);
71-
logger('Connected Puppeteer');
72-
return browser;
85+
try {
86+
browser = await puppeteer.connect(connectOptions);
87+
logger('Connected Puppeteer successfully');
88+
logger('Browser object type:', typeof browser);
89+
logger('Browser.connected:', browser?.connected);
90+
return browser;
91+
} catch (error) {
92+
const message = error instanceof Error ? error.message : String(error);
93+
logger('Failed to connect to Puppeteer:', message);
94+
throw new Error(`Puppeteer connection failed: ${message}`);
95+
}
7396
}
7497

7598
interface McpLaunchOptions {

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ export const cliOptions = {
160160
default: true,
161161
describe: 'Set to false to exclude tools related to network.',
162162
},
163+
noLaunch: {
164+
type: 'boolean',
165+
default: false,
166+
describe:
167+
'Do not launch or connect to a browser automatically. Use switch_browser tool to connect manually.',
168+
},
163169
} satisfies Record<string, YargsOptions>;
164170

165171
export function parseArguments(version: string, argv = process.argv) {
@@ -170,6 +176,7 @@ export function parseArguments(version: string, argv = process.argv) {
170176
// We can't set default in the options else
171177
// Yargs will complain
172178
if (
179+
!args.noLaunch &&
173180
!args.channel &&
174181
!args.browserUrl &&
175182
!args.wsEndpoint &&

src/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {parseArguments} from './cli.js';
8+
9+
// If moved update release-please config
10+
// x-release-please-start-version
11+
const VERSION = '0.10.1';
12+
// x-release-please-end
13+
14+
export const args = parseArguments(VERSION);
15+
export {VERSION};

src/context.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {ensureBrowserConnected} from './browser.js';
8+
import {logger} from './logger.js';
9+
import {McpContext} from './McpContext.js';
10+
11+
let context: McpContext | undefined;
12+
13+
export interface SetContextOptions {
14+
browserURL?: string;
15+
wsEndpoint?: string;
16+
devtools?: boolean;
17+
experimentalIncludeAllPages?: boolean;
18+
timeout?: number;
19+
}
20+
21+
export async function setContext(
22+
options: SetContextOptions,
23+
): Promise<McpContext> {
24+
const {
25+
browserURL,
26+
wsEndpoint,
27+
devtools = false,
28+
experimentalIncludeAllPages = false,
29+
timeout,
30+
} = options;
31+
32+
logger('setContext called with:', {
33+
browserURL,
34+
wsEndpoint,
35+
devtools,
36+
timeout,
37+
});
38+
39+
const connectPromise = ensureBrowserConnected({
40+
browserURL,
41+
wsEndpoint,
42+
devtools,
43+
});
44+
45+
let browser;
46+
logger('Starting browser connection, timeout:', timeout);
47+
if (timeout) {
48+
let timeoutId: NodeJS.Timeout | undefined;
49+
const timeoutPromise = new Promise<never>((_, reject) => {
50+
timeoutId = setTimeout(
51+
() =>
52+
reject(
53+
new Error(
54+
`Failed to connect to browser within ${timeout}ms. Please check that the browser is running and accessible at the provided URL.`,
55+
),
56+
),
57+
timeout,
58+
);
59+
});
60+
61+
try {
62+
browser = await Promise.race([connectPromise, timeoutPromise]);
63+
} finally {
64+
// Clear timeout to prevent it from firing after connection succeeds
65+
if (timeoutId) {
66+
clearTimeout(timeoutId);
67+
}
68+
}
69+
} else {
70+
browser = await connectPromise;
71+
}
72+
73+
logger('Browser connection completed, browser type:', typeof browser);
74+
logger('Browser connected status:', browser?.connected);
75+
76+
if (!browser) {
77+
throw new Error(
78+
'Failed to connect to browser: browser object is undefined',
79+
);
80+
}
81+
82+
logger('Creating McpContext from browser...');
83+
context = await McpContext.from(browser, logger, {
84+
experimentalDevToolsDebugging: devtools,
85+
experimentalIncludeAllPages,
86+
});
87+
88+
logger('McpContext created successfully');
89+
return context;
90+
}
91+
92+
export function getContext(): McpContext | undefined {
93+
return context;
94+
}
95+
96+
export function setContextInstance(newContext: McpContext | undefined): void {
97+
context = newContext;
98+
}

src/main.ts

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import './polyfill.js';
88

99
import type {Channel} from './browser.js';
1010
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
11-
import {parseArguments} from './cli.js';
11+
import {args, VERSION} from './config.js';
12+
import {
13+
getContext as getContextInstance,
14+
setContextInstance,
15+
} from './context.js';
1216
import {loadIssueDescriptions} from './issue-descriptions.js';
1317
import {logger, saveLogsToFile} from './logger.js';
1418
import {McpContext} from './McpContext.js';
@@ -30,15 +34,9 @@ import * as performanceTools from './tools/performance.js';
3034
import * as screenshotTools from './tools/screenshot.js';
3135
import * as scriptTools from './tools/script.js';
3236
import * as snapshotTools from './tools/snapshot.js';
37+
import * as switchBrowserTool from './tools/switch_browser.js';
3338
import type {ToolDefinition} from './tools/ToolDefinition.js';
3439

35-
// If moved update release-please config
36-
// x-release-please-start-version
37-
const VERSION = '0.10.1';
38-
// x-release-please-end
39-
40-
export const args = parseArguments(VERSION);
41-
4240
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
4341

4442
logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
@@ -54,39 +52,57 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => {
5452
return {};
5553
});
5654

57-
let context: McpContext;
5855
async function getContext(): Promise<McpContext> {
56+
let context = getContextInstance();
57+
58+
if (args.noLaunch && !context) {
59+
throw new Error(
60+
'No browser connected. Use the switch_browser tool to connect to a browser instance.',
61+
);
62+
}
63+
5964
const extraArgs: string[] = (args.chromeArg ?? []).map(String);
6065
if (args.proxyServer) {
6166
extraArgs.push(`--proxy-server=${args.proxyServer}`);
6267
}
6368
const devtools = args.experimentalDevtools ?? false;
64-
const browser =
65-
args.browserUrl || args.wsEndpoint
66-
? await ensureBrowserConnected({
67-
browserURL: args.browserUrl,
68-
wsEndpoint: args.wsEndpoint,
69-
wsHeaders: args.wsHeaders,
70-
devtools,
71-
})
72-
: await ensureBrowserLaunched({
73-
headless: args.headless,
74-
executablePath: args.executablePath,
75-
channel: args.channel as Channel,
76-
isolated: args.isolated,
77-
logFile,
78-
viewport: args.viewport,
79-
args: extraArgs,
80-
acceptInsecureCerts: args.acceptInsecureCerts,
81-
devtools,
82-
});
83-
84-
if (context?.browser !== browser) {
85-
context = await McpContext.from(browser, logger, {
86-
experimentalDevToolsDebugging: devtools,
87-
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
88-
});
69+
70+
if (!args.noLaunch) {
71+
const browser =
72+
args.browserUrl || args.wsEndpoint
73+
? await ensureBrowserConnected({
74+
browserURL: args.browserUrl,
75+
wsEndpoint: args.wsEndpoint,
76+
wsHeaders: args.wsHeaders,
77+
devtools,
78+
})
79+
: await ensureBrowserLaunched({
80+
headless: args.headless,
81+
executablePath: args.executablePath,
82+
channel: args.channel as Channel,
83+
isolated: args.isolated,
84+
logFile,
85+
viewport: args.viewport,
86+
args: extraArgs,
87+
acceptInsecureCerts: args.acceptInsecureCerts,
88+
devtools,
89+
});
90+
91+
if (context?.browser !== browser) {
92+
context = await McpContext.from(browser, logger, {
93+
experimentalDevToolsDebugging: devtools,
94+
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
95+
});
96+
setContextInstance(context);
97+
}
8998
}
99+
100+
if (!context) {
101+
throw new Error(
102+
'Failed to initialize browser context. This should not happen.',
103+
);
104+
}
105+
90106
return context;
91107
}
92108

@@ -180,6 +196,7 @@ const tools = [
180196
...Object.values(screenshotTools),
181197
...Object.values(scriptTools),
182198
...Object.values(snapshotTools),
199+
...Object.values(switchBrowserTool),
183200
] as ToolDefinition[];
184201

185202
tools.sort((a, b) => {

0 commit comments

Comments
 (0)