Skip to content

Commit 766afc7

Browse files
authored
Merge pull request #43 from webdriverio/feature/launch-chrome
feat: Add `launch_chrome` tool for launching Chrome with remote debugging
2 parents 3ed05a3 + eab139c commit 766afc7

4 files changed

Lines changed: 329 additions & 61 deletions

File tree

src/server.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
2-
3-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import pkg from '../package.json' with { type: 'json' };
3+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
44
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
55
import type { ToolDefinition } from './types/tool';
66
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
@@ -55,11 +55,10 @@ import {
5555
} from './tools/device.tool';
5656
import { executeScriptTool, executeScriptToolDefinition } from './tools/execute-script.tool';
5757
import { attachBrowserTool, attachBrowserToolDefinition } from './tools/attach-browser.tool';
58+
import { launchChromeTool, launchChromeToolDefinition } from './tools/launch-chrome.tool';
5859
import { emulateDeviceTool, emulateDeviceToolDefinition } from './tools/emulate-device.tool';
59-
import pkg from '../package.json' with { type: 'json' };
60-
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
6160
import { withRecording } from './recording/step-recorder';
62-
import { buildSessionsIndex, buildCurrentSessionSteps, buildSessionStepsById } from './recording/resources';
61+
import { buildCurrentSessionSteps, buildSessionsIndex, buildSessionStepsById } from './recording/resources';
6362

6463
// IMPORTANT: Redirect all console output to stderr to avoid messing with MCP protocol (Chrome writes to console)
6564
const _originalConsoleLog = console.log;
@@ -97,6 +96,7 @@ const registerTool = (definition: ToolDefinition, callback: ToolCallback) =>
9796
registerTool(startBrowserToolDefinition, withRecording('start_browser', startBrowserTool));
9897
registerTool(startAppToolDefinition, withRecording('start_app_session', startAppTool));
9998
registerTool(closeSessionToolDefinition, closeSessionTool);
99+
registerTool(launchChromeToolDefinition, withRecording('launch_chrome', launchChromeTool));
100100
registerTool(attachBrowserToolDefinition, withRecording('attach_browser', attachBrowserTool));
101101
registerTool(emulateDeviceToolDefinition, emulateDeviceTool);
102102
registerTool(navigateToolDefinition, withRecording('navigate', navigateTool));
@@ -159,7 +159,11 @@ server.registerResource(
159159
async () => {
160160
const payload = buildCurrentSessionSteps();
161161
return {
162-
contents: [{ uri: 'wdio://session/current/steps', mimeType: 'application/json', text: payload?.stepsJson ?? '{"error":"No active session"}' }],
162+
contents: [{
163+
uri: 'wdio://session/current/steps',
164+
mimeType: 'application/json',
165+
text: payload?.stepsJson ?? '{"error":"No active session"}'
166+
}],
163167
};
164168
},
165169
);
@@ -171,7 +175,11 @@ server.registerResource(
171175
async () => {
172176
const payload = buildCurrentSessionSteps();
173177
return {
174-
contents: [{ uri: 'wdio://session/current/code', mimeType: 'text/plain', text: payload?.generatedJs ?? '// No active session' }],
178+
contents: [{
179+
uri: 'wdio://session/current/code',
180+
mimeType: 'text/plain',
181+
text: payload?.generatedJs ?? '// No active session'
182+
}],
175183
};
176184
},
177185
);
@@ -183,7 +191,11 @@ server.registerResource(
183191
async (uri, { sessionId }) => {
184192
const payload = buildSessionStepsById(sessionId as string);
185193
return {
186-
contents: [{ uri: uri.href, mimeType: 'application/json', text: payload?.stepsJson ?? `{"error":"Session not found: ${sessionId}"}` }],
194+
contents: [{
195+
uri: uri.href,
196+
mimeType: 'application/json',
197+
text: payload?.stepsJson ?? `{"error":"Session not found: ${sessionId}"}`
198+
}],
187199
};
188200
},
189201
);
@@ -195,7 +207,11 @@ server.registerResource(
195207
async (uri, { sessionId }) => {
196208
const payload = buildSessionStepsById(sessionId as string);
197209
return {
198-
contents: [{ uri: uri.href, mimeType: 'text/plain', text: payload?.generatedJs ?? `// Session not found: ${sessionId}` }],
210+
contents: [{
211+
uri: uri.href,
212+
mimeType: 'text/plain',
213+
text: payload?.generatedJs ?? `// Session not found: ${sessionId}`
214+
}],
199215
};
200216
},
201217
);

src/tools/attach-browser.tool.ts

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,67 +9,106 @@ export const attachBrowserToolDefinition: ToolDefinition = {
99
name: 'attach_browser',
1010
description: `Attach to a Chrome instance already running with --remote-debugging-port.
1111
12-
Start Chrome first (quit any running Chrome instance before launching):
13-
14-
macOS — with real profile (preserves extensions, cookies, logins):
15-
pkill -x "Google Chrome" && sleep 1
16-
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/Library/Application Support/Google/Chrome" --profile-directory=Default &
17-
18-
macOS — with fresh profile (lightweight, no extensions):
19-
pkill -x "Google Chrome" && sleep 1
20-
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug &
21-
22-
Linux — with real profile:
23-
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/google-chrome" --profile-directory=Default &
24-
25-
Linux — with fresh profile:
26-
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug &
27-
28-
Verify Chrome is ready: curl http://localhost:9222/json/version
29-
30-
Then call attach_browser() to hand control to the AI. All other tools (navigate, click, get_visible_elements, etc.) will work on the attached session. Use close_session() to detach without closing Chrome.`,
12+
Use launch_chrome() first to prepare and launch Chrome with remote debugging enabled.`,
3113
inputSchema: {
3214
port: z.number().default(9222).describe('Chrome remote debugging port (default: 9222)'),
3315
host: z.string().default('localhost').describe('Host where Chrome is running (default: localhost)'),
34-
userDataDir: z.string().default('/tmp/chrome-debug').describe('Chrome user data directory — must match the --user-data-dir used when launching Chrome. Use your real profile path (e.g. "$HOME/Library/Application Support/Google/Chrome") to preserve extensions and logins, or /tmp/chrome-debug for a fresh profile (default: /tmp/chrome-debug)'),
3516
navigationUrl: z.string().optional().describe('URL to navigate to immediately after attaching'),
3617
},
3718
};
3819

39-
async function getActiveTabUrl(host: string, port: number): Promise<string | null> {
20+
type TabSnapshot = { activeTabUrl: string | undefined; allTabUrls: string[] };
21+
22+
// ChromeDriver injects a BiDi-CDP Mapper page when creating a session. If the previous session
23+
// was detached without proper cleanup, this target remains and causes "unexpected alert open" on
24+
// the next attach attempt. Close any stale mappers before creating a new session.
25+
// Returns the active tab URL (first real page tab) and all page tab URLs — Chrome lists the
26+
// active/focused tab first in /json.
27+
async function closeStaleMappers(host: string, port: number): Promise<TabSnapshot> {
4028
try {
4129
const res = await fetch(`http://${host}:${port}/json`);
42-
const tabs = await res.json() as { type: string; url: string }[];
43-
const page = tabs.find((t) => t.type === 'page' && t.url && !t.url.startsWith('devtools://'));
44-
return page?.url ?? null;
30+
const targets = await res.json() as { id: string; title: string; type: string; url: string }[];
31+
const mappers = targets.filter((t) => t.title?.includes('BiDi'));
32+
await Promise.all(mappers.map((t) => fetch(`http://${host}:${port}/json/close/${t.id}`)));
33+
const pages = targets.filter((t) => t.type === 'page' && !t.title?.includes('BiDi'));
34+
return { activeTabUrl: pages[0]?.url, allTabUrls: pages.map((t) => t.url) };
4535
} catch {
46-
return null;
36+
return { activeTabUrl: undefined, allTabUrls: [] };
37+
}
38+
}
39+
40+
// After CDP session init, Chrome blanks the first tab it takes over. This restores any tabs
41+
// that became about:blank and then switches focus to the originally active tab.
42+
async function restoreAndSwitchToActiveTab(
43+
browser: WebdriverIO.Browser,
44+
activeTabUrl: string,
45+
allTabUrls: string[],
46+
): Promise<void> {
47+
const handles = await browser.getWindowHandles();
48+
const currentUrls: string[] = [];
49+
for (const handle of handles) {
50+
await browser.switchToWindow(handle);
51+
currentUrls.push(await browser.getUrl());
52+
}
53+
54+
// Restore blank tabs that had a known URL before attaching.
55+
const missingUrls = allTabUrls.filter((u) => !currentUrls.includes(u));
56+
let missingIdx = 0;
57+
for (let i = 0; i < handles.length; i++) {
58+
if (currentUrls[i] === 'about:blank' && missingIdx < missingUrls.length) {
59+
await browser.switchToWindow(handles[i]);
60+
await browser.url(missingUrls[missingIdx]);
61+
currentUrls[i] = missingUrls[missingIdx++];
62+
}
63+
}
64+
65+
// Switch to the originally active tab.
66+
for (let i = 0; i < handles.length; i++) {
67+
if (currentUrls[i] === activeTabUrl) {
68+
await browser.switchToWindow(handles[i]);
69+
break;
70+
}
71+
}
72+
}
73+
74+
async function waitForCDP(host: string, port: number, timeoutMs = 10000): Promise<void> {
75+
const deadline = Date.now() + timeoutMs;
76+
while (Date.now() < deadline) {
77+
try {
78+
const res = await fetch(`http://${host}:${port}/json/version`);
79+
if (res.ok) return;
80+
} catch {
81+
// not ready yet
82+
}
83+
await new Promise((r) => setTimeout(r, 300));
4784
}
85+
throw new Error(`Chrome did not expose CDP on ${host}:${port} within ${timeoutMs}ms`);
4886
}
4987

5088
export const attachBrowserTool: ToolCallback = async ({
5189
port = 9222,
5290
host = 'localhost',
53-
userDataDir = '/tmp/chrome-debug',
5491
navigationUrl,
5592
}: {
5693
port?: number;
5794
host?: string;
58-
userDataDir?: string;
5995
navigationUrl?: string;
6096
}): Promise<CallToolResult> => {
6197
try {
6298
const state = (getBrowser as any).__state;
6399

64-
// Capture the active tab URL before WebDriver blanks it
65-
const activeUrl = navigationUrl ?? await getActiveTabUrl(host, port);
100+
await waitForCDP(host, port);
101+
const { activeTabUrl, allTabUrls } = await closeStaleMappers(host, port);
66102

67103
const browser = await remote({
104+
connectionRetryTimeout: 30000,
105+
connectionRetryCount: 3,
68106
capabilities: {
69107
browserName: 'chrome',
108+
unhandledPromptBehavior: 'dismiss',
109+
webSocketUrl: false,
70110
'goog:chromeOptions': {
71111
debuggerAddress: `${host}:${port}`,
72-
args: [`--user-data-dir=${userDataDir}`],
73112
},
74113
},
75114
});
@@ -90,14 +129,15 @@ export const attachBrowserTool: ToolCallback = async ({
90129
browserName: 'chrome',
91130
'goog:chromeOptions': {
92131
debuggerAddress: `${host}:${port}`,
93-
args: [`--user-data-dir=${userDataDir}`],
94132
},
95133
},
96134
steps: [],
97135
});
98136

99-
if (activeUrl) {
100-
await browser.url(activeUrl);
137+
if (navigationUrl) {
138+
await browser.url(navigationUrl);
139+
} else if (activeTabUrl) {
140+
await restoreAndSwitchToActiveTab(browser, activeTabUrl, allTabUrls);
101141
}
102142

103143
const title = await browser.getTitle();

src/tools/launch-chrome.tool.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { spawn } from 'node:child_process';
2+
import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3+
import { homedir, platform, tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp';
6+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types';
7+
import type { ToolDefinition } from '../types/tool';
8+
import { z } from 'zod';
9+
10+
const USER_DATA_DIR = join(tmpdir(), 'chrome-debug');
11+
12+
export const launchChromeToolDefinition: ToolDefinition = {
13+
name: 'launch_chrome',
14+
description: `Prepares and launches Chrome with remote debugging enabled so attach_browser() can connect.
15+
16+
Two modes:
17+
18+
newInstance (default): Opens a Chrome window alongside your existing one using a separate
19+
profile dir. Your current Chrome session is untouched.
20+
21+
freshSession: Launches Chrome with an empty profile (no cookies, no logins).
22+
23+
Use copyProfileFiles: true to carry over your cookies and logins into the debug session.
24+
Note: changes made during the session won't sync back to your main profile.
25+
26+
After this tool succeeds, call attach_browser() to connect.`,
27+
inputSchema: {
28+
port: z.number().default(9222).describe('Remote debugging port (default: 9222)'),
29+
mode: z.enum(['newInstance', 'freshSession']).default('newInstance').describe(
30+
'newInstance: open alongside existing Chrome | freshSession: clean profile'
31+
),
32+
copyProfileFiles: z.boolean().default(false).describe(
33+
'Copy your Default Chrome profile (cookies, logins) into the debug session.'
34+
),
35+
},
36+
};
37+
38+
function isMac(): boolean {
39+
return platform() === 'darwin';
40+
}
41+
42+
function chromeExec(): string {
43+
if (isMac()) return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
44+
if (platform() === 'win32') {
45+
const candidates = [
46+
join('C:', 'Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
47+
join('C:', 'Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
48+
];
49+
return candidates.find((p) => existsSync(p)) ?? candidates[0];
50+
}
51+
return 'google-chrome';
52+
}
53+
54+
function defaultProfileDir(): string {
55+
const home = homedir();
56+
if (isMac()) return join(home, 'Library', 'Application Support', 'Google', 'Chrome');
57+
if (platform() === 'win32') return join(home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data');
58+
return join(home, '.config', 'google-chrome');
59+
}
60+
61+
function copyProfile(): void {
62+
const srcDir = defaultProfileDir();
63+
rmSync(USER_DATA_DIR, { recursive: true, force: true });
64+
mkdirSync(USER_DATA_DIR, { recursive: true });
65+
copyFileSync(join(srcDir, 'Local State'), join(USER_DATA_DIR, 'Local State'));
66+
cpSync(join(srcDir, 'Default'), join(USER_DATA_DIR, 'Default'), { recursive: true });
67+
68+
// Remove singleton/lock files from the source Chrome instance.
69+
for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
70+
rmSync(join(USER_DATA_DIR, f), { force: true });
71+
}
72+
73+
// Remove session files — they reference the original profile's state and trigger
74+
// "Something went wrong when opening your profile" when Chrome opens the copy.
75+
for (const f of ['Current Session', 'Current Tabs', 'Last Session', 'Last Tabs']) {
76+
rmSync(join(USER_DATA_DIR, 'Default', f), { force: true });
77+
}
78+
79+
// First Run sentinel tells Chrome this is a fresh start — suppresses first-run dialogs.
80+
writeFileSync(join(USER_DATA_DIR, 'First Run'), '');
81+
}
82+
83+
function launchChrome(port: number): void {
84+
spawn(chromeExec(), [
85+
`--remote-debugging-port=${port}`,
86+
`--user-data-dir=${USER_DATA_DIR}`,
87+
'--profile-directory=Default',
88+
'--no-first-run',
89+
'--disable-session-crashed-bubble',
90+
], { detached: true, stdio: 'ignore' }).unref();
91+
}
92+
93+
async function waitForCDP(port: number, timeoutMs = 15000): Promise<void> {
94+
const deadline = Date.now() + timeoutMs;
95+
while (Date.now() < deadline) {
96+
try {
97+
const res = await fetch(`http://localhost:${port}/json/version`);
98+
if (res.ok) return;
99+
} catch {
100+
// not ready yet
101+
}
102+
await new Promise((r) => setTimeout(r, 300));
103+
}
104+
throw new Error(`Chrome did not expose CDP on port ${port} within ${timeoutMs}ms`);
105+
}
106+
107+
export const launchChromeTool: ToolCallback = async ({
108+
port = 9222,
109+
mode = 'newInstance',
110+
copyProfileFiles = false,
111+
}: {
112+
port?: number;
113+
mode?: 'newInstance' | 'freshSession';
114+
copyProfileFiles?: boolean;
115+
}): Promise<CallToolResult> => {
116+
const warnings: string[] = [];
117+
const notes: string[] = [];
118+
119+
try {
120+
if (copyProfileFiles) {
121+
warnings.push('⚠️ Cookies and logins were copied at this moment. Changes during this session won\'t sync back to your main profile.');
122+
copyProfile();
123+
} else {
124+
notes.push(mode === 'newInstance'
125+
? 'No profile copied — this instance starts with no cookies or logins.'
126+
: 'Fresh profile — no existing cookies or logins.');
127+
rmSync(USER_DATA_DIR, { recursive: true, force: true });
128+
mkdirSync(USER_DATA_DIR, { recursive: true });
129+
}
130+
131+
launchChrome(port);
132+
await waitForCDP(port);
133+
134+
const lines = [
135+
`Chrome launched on port ${port} (mode: ${mode}).`,
136+
...warnings,
137+
...notes,
138+
];
139+
140+
return { content: [{ type: 'text', text: lines.join('\n') }] };
141+
} catch (e) {
142+
return {
143+
isError: true,
144+
content: [{ type: 'text', text: `Error launching Chrome: ${e}` }],
145+
};
146+
}
147+
};

0 commit comments

Comments
 (0)