@mcp-b/chrome-devtools-mcp 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,16 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { logger } from './logger.js';
10
10
  import { puppeteer } from './third_party/index.js';
11
+ /** Cached browser instance for reuse across calls. */
11
12
  let browser;
13
+ /**
14
+ * Create a target filter for Puppeteer that excludes internal Chrome pages.
15
+ *
16
+ * Includes new tab and inspect pages (may be the only user-accessible page),
17
+ * but excludes chrome://, chrome-extension://, and chrome-untrusted:// pages.
18
+ *
19
+ * @returns A filter function for Puppeteer's targetFilter option.
20
+ */
12
21
  function makeTargetFilter() {
13
22
  const ignoredPrefixes = new Set([
14
23
  'chrome://',
@@ -19,7 +28,6 @@ function makeTargetFilter() {
19
28
  if (target.url() === 'chrome://newtab/') {
20
29
  return true;
21
30
  }
22
- // Could be the only page opened in the browser.
23
31
  if (target.url().startsWith('chrome://inspect')) {
24
32
  return true;
25
33
  }
@@ -31,7 +39,21 @@ function makeTargetFilter() {
31
39
  return true;
32
40
  };
33
41
  }
42
+ /**
43
+ * Connect to an existing Chrome browser instance.
44
+ *
45
+ * Connection priority:
46
+ * 1. wsEndpoint - Direct WebSocket connection with optional headers
47
+ * 2. browserURL - HTTP URL to Chrome's DevTools endpoint
48
+ * 3. userDataDir - Read DevToolsActivePort from profile directory
49
+ * 4. channel - Derive profile directory from channel name
50
+ *
51
+ * @param options - Connection options.
52
+ * @returns Connected browser instance.
53
+ * @throws Error if connection fails or no connection method specified.
54
+ */
34
55
  export async function ensureBrowserConnected(options) {
56
+ const { channel } = options;
35
57
  if (browser?.connected) {
36
58
  return browser;
37
59
  }
@@ -49,14 +71,91 @@ export async function ensureBrowserConnected(options) {
49
71
  else if (options.browserURL) {
50
72
  connectOptions.browserURL = options.browserURL;
51
73
  }
74
+ else if (channel || options.userDataDir) {
75
+ const userDataDir = options.userDataDir;
76
+ if (userDataDir) {
77
+ // TODO: re-expose this logic via Puppeteer.
78
+ const portPath = path.join(userDataDir, 'DevToolsActivePort');
79
+ try {
80
+ const fileContent = await fs.promises.readFile(portPath, 'utf8');
81
+ const [rawPort, rawPath] = fileContent
82
+ .split('\n')
83
+ .map(line => {
84
+ return line.trim();
85
+ })
86
+ .filter(line => {
87
+ return !!line;
88
+ });
89
+ if (!rawPort || !rawPath) {
90
+ throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
91
+ }
92
+ const port = parseInt(rawPort, 10);
93
+ if (isNaN(port) || port <= 0 || port > 65535) {
94
+ throw new Error(`Invalid port '${rawPort}' found`);
95
+ }
96
+ const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
97
+ connectOptions.browserWSEndpoint = browserWSEndpoint;
98
+ }
99
+ catch (error) {
100
+ throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled.`, {
101
+ cause: error,
102
+ });
103
+ }
104
+ }
105
+ else {
106
+ if (!channel) {
107
+ throw new Error('Channel must be provided if userDataDir is missing');
108
+ }
109
+ // Derive the default userDataDir from the channel (same as launch does)
110
+ const profileDirName = channel && channel !== 'stable'
111
+ ? `chrome-profile-${channel}`
112
+ : 'chrome-profile';
113
+ const derivedUserDataDir = path.join(os.homedir(), '.cache', 'chrome-devtools-mcp', profileDirName);
114
+ // Try to read DevToolsActivePort from the derived userDataDir
115
+ const portPath = path.join(derivedUserDataDir, 'DevToolsActivePort');
116
+ try {
117
+ const fileContent = await fs.promises.readFile(portPath, 'utf8');
118
+ const [rawPort, rawPath] = fileContent
119
+ .split('\n')
120
+ .map(line => line.trim())
121
+ .filter(line => !!line);
122
+ if (!rawPort || !rawPath) {
123
+ throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
124
+ }
125
+ const port = parseInt(rawPort, 10);
126
+ if (isNaN(port) || port <= 0 || port > 65535) {
127
+ throw new Error(`Invalid port '${rawPort}' found`);
128
+ }
129
+ const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
130
+ connectOptions.browserWSEndpoint = browserWSEndpoint;
131
+ }
132
+ catch (error) {
133
+ throw new Error(`Could not connect to Chrome ${channel} channel in ${derivedUserDataDir}. Check if Chrome is running and was launched with remote debugging enabled.`, { cause: error });
134
+ }
135
+ }
136
+ }
52
137
  else {
53
- throw new Error('Either browserURL or wsEndpoint must be provided');
138
+ throw new Error('Either browserURL, wsEndpoint, channel or userDataDir must be provided');
54
139
  }
55
140
  logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
56
- browser = await puppeteer.connect(connectOptions);
141
+ try {
142
+ browser = await puppeteer.connect(connectOptions);
143
+ }
144
+ catch (err) {
145
+ throw new Error('Could not connect to Chrome. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.', {
146
+ cause: err,
147
+ });
148
+ }
57
149
  logger('Connected Puppeteer');
58
150
  return browser;
59
151
  }
152
+ /**
153
+ * Launch a new Chrome browser instance.
154
+ *
155
+ * @param options - Launch configuration options.
156
+ * @returns Launched browser instance.
157
+ * @throws Error if Chrome is already running with the same profile.
158
+ */
60
159
  export async function launch(options) {
61
160
  const { channel, executablePath, headless, isolated } = options;
62
161
  const profileDirName = channel && channel !== 'stable'
@@ -69,10 +168,21 @@ export async function launch(options) {
69
168
  recursive: true,
70
169
  });
71
170
  }
171
+ const extraArgs = options.args ?? [];
172
+ const hasRemoteDebuggingPipe = extraArgs.includes('--remote-debugging-pipe');
173
+ const hasRemoteDebuggingPort = extraArgs.some(arg => arg.startsWith('--remote-debugging-port'));
174
+ const hasRemoteDebuggingAddress = extraArgs.some(arg => arg.startsWith('--remote-debugging-address'));
72
175
  const args = [
73
- ...(options.args ?? []),
176
+ ...extraArgs,
74
177
  '--hide-crash-restore-bubble',
75
178
  ];
179
+ const enableRemoteDebuggingPort = !isolated && !hasRemoteDebuggingPipe && !hasRemoteDebuggingPort;
180
+ if (enableRemoteDebuggingPort) {
181
+ args.push('--remote-debugging-port=0');
182
+ if (!hasRemoteDebuggingAddress) {
183
+ args.push('--remote-debugging-address=127.0.0.1');
184
+ }
185
+ }
76
186
  if (headless) {
77
187
  args.push('--screen-info={3840x2160}');
78
188
  }
@@ -86,6 +196,8 @@ export async function launch(options) {
86
196
  ? `chrome-${channel}`
87
197
  : 'chrome';
88
198
  }
199
+ const usePipe = hasRemoteDebuggingPipe ||
200
+ (!hasRemoteDebuggingPort && !enableRemoteDebuggingPort);
89
201
  try {
90
202
  const browser = await puppeteer.launch({
91
203
  channel: puppeteerChannel,
@@ -93,7 +205,7 @@ export async function launch(options) {
93
205
  executablePath,
94
206
  defaultViewport: null,
95
207
  userDataDir,
96
- pipe: true,
208
+ pipe: usePipe,
97
209
  headless,
98
210
  args,
99
211
  acceptInsecureCerts: options.acceptInsecureCerts,
@@ -125,6 +237,12 @@ export async function launch(options) {
125
237
  throw error;
126
238
  }
127
239
  }
240
+ /**
241
+ * Ensure a browser is launched, reusing existing instance if connected.
242
+ *
243
+ * @param options - Launch configuration options.
244
+ * @returns Connected or newly launched browser instance.
245
+ */
128
246
  export async function ensureBrowserLaunched(options) {
129
247
  if (browser?.connected) {
130
248
  return browser;
package/build/src/cli.js CHANGED
@@ -5,6 +5,17 @@
5
5
  */
6
6
  import { yargs, hideBin } from './third_party/index.js';
7
7
  export const cliOptions = {
8
+ autoConnect: {
9
+ type: 'boolean',
10
+ description: 'If specified, automatically connects to a browser (Chrome 145+) running in the user data directory identified by the channel param. Falls back to launching a new instance if no running browser is found.',
11
+ default: true,
12
+ coerce: (value) => {
13
+ if (value === false) {
14
+ return false;
15
+ }
16
+ return true;
17
+ },
18
+ },
8
19
  browserUrl: {
9
20
  type: 'string',
10
21
  description: 'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.',
@@ -163,7 +174,7 @@ export function parseArguments(version, argv = process.argv) {
163
174
  !args.browserUrl &&
164
175
  !args.wsEndpoint &&
165
176
  !args.executablePath) {
166
- args.channel = 'stable';
177
+ args.channel = 'dev';
167
178
  }
168
179
  return true;
169
180
  })
@@ -204,6 +215,14 @@ export function parseArguments(version, argv = process.argv) {
204
215
  '$0 --user-data-dir=/tmp/user-data-dir',
205
216
  'Use a custom user data directory',
206
217
  ],
218
+ [
219
+ '$0 --auto-connect',
220
+ 'Connect to a stable Chrome instance (Chrome 145+) running instead of launching a new instance',
221
+ ],
222
+ [
223
+ '$0 --auto-connect --channel=canary',
224
+ 'Connect to a canary Chrome instance (Chrome 145+) running instead of launching a new instance',
225
+ ],
207
226
  ]);
208
227
  return yargsInstance
209
228
  .wrap(Math.min(120, yargsInstance.terminalWidth()))
package/build/src/main.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ import process from 'node:process';
6
7
  import './polyfill.js';
7
8
  import { ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
8
9
  import { parseArguments } from './cli.js';
@@ -15,10 +16,17 @@ import { McpServer, StdioServerTransport, SetLevelRequestSchema, } from './third
15
16
  import { registerPrompts } from './prompts/index.js';
16
17
  import { ToolCategory } from './tools/categories.js';
17
18
  import { tools } from './tools/tools.js';
18
- // If moved update release-please config
19
+ import { WebMCPToolHub } from './tools/WebMCPToolHub.js';
20
+ /**
21
+ * Package version (managed by release-please).
22
+ * @remarks If moved, update release-please config.
23
+ */
19
24
  // x-release-please-start-version
20
- const VERSION = '0.11.0';
25
+ const VERSION = '0.12.1';
21
26
  // x-release-please-end
27
+ process.on('unhandledRejection', (reason, promise) => {
28
+ logger('Unhandled promise rejection', promise, reason);
29
+ });
22
30
  export const args = parseArguments(VERSION);
23
31
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
24
32
  logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
@@ -26,27 +34,75 @@ const server = new McpServer({
26
34
  name: 'chrome_devtools',
27
35
  title: 'Chrome DevTools MCP server',
28
36
  version: VERSION,
29
- }, { capabilities: { logging: {}, prompts: {} } });
37
+ }, { capabilities: { logging: {}, prompts: {}, tools: { listChanged: true } } });
30
38
  // Register WebMCP development prompts
31
39
  registerPrompts(server);
32
40
  server.server.setRequestHandler(SetLevelRequestSchema, () => {
33
41
  return {};
34
42
  });
43
+ /** Cached McpContext instance for the current browser. */
35
44
  let context;
45
+ /**
46
+ * Get or create the McpContext for browser operations.
47
+ *
48
+ * Handles browser connection/launch with the following priority:
49
+ * 1. Explicit browserUrl/wsEndpoint - connect directly
50
+ * 2. autoConnect enabled - try connecting, fall back to launching
51
+ * 3. Otherwise - launch a new browser
52
+ *
53
+ * @returns Initialized McpContext ready for tool operations.
54
+ */
36
55
  async function getContext() {
37
56
  const extraArgs = (args.chromeArg ?? []).map(String);
38
57
  if (args.proxyServer) {
39
58
  extraArgs.push(`--proxy-server=${args.proxyServer}`);
40
59
  }
41
60
  const devtools = args.experimentalDevtools ?? false;
42
- const browser = args.browserUrl || args.wsEndpoint
43
- ? await ensureBrowserConnected({
61
+ let browser;
62
+ // If explicit browserUrl or wsEndpoint is provided, connect without fallback
63
+ if (args.browserUrl || args.wsEndpoint) {
64
+ browser = await ensureBrowserConnected({
44
65
  browserURL: args.browserUrl,
45
66
  wsEndpoint: args.wsEndpoint,
46
67
  wsHeaders: args.wsHeaders,
47
68
  devtools,
48
- })
49
- : await ensureBrowserLaunched({
69
+ channel: undefined,
70
+ userDataDir: args.userDataDir,
71
+ });
72
+ }
73
+ // If autoConnect is true, try connecting first, then fall back to launching
74
+ else if (args.autoConnect) {
75
+ try {
76
+ logger('Attempting to connect to running browser instance...');
77
+ browser = await ensureBrowserConnected({
78
+ browserURL: undefined,
79
+ wsEndpoint: undefined,
80
+ wsHeaders: undefined,
81
+ devtools,
82
+ channel: args.channel,
83
+ userDataDir: args.userDataDir,
84
+ });
85
+ logger('Successfully connected to running browser instance');
86
+ }
87
+ catch (err) {
88
+ logger('Failed to connect to running browser, launching new instance...', err);
89
+ browser = await ensureBrowserLaunched({
90
+ headless: args.headless,
91
+ executablePath: args.executablePath,
92
+ channel: args.channel,
93
+ isolated: args.isolated ?? false,
94
+ userDataDir: args.userDataDir,
95
+ logFile,
96
+ viewport: args.viewport,
97
+ args: extraArgs,
98
+ acceptInsecureCerts: args.acceptInsecureCerts,
99
+ devtools,
100
+ });
101
+ }
102
+ }
103
+ // Otherwise, just launch a new browser
104
+ else {
105
+ browser = await ensureBrowserLaunched({
50
106
  headless: args.headless,
51
107
  executablePath: args.executablePath,
52
108
  channel: args.channel,
@@ -58,20 +114,41 @@ async function getContext() {
58
114
  acceptInsecureCerts: args.acceptInsecureCerts,
59
115
  devtools,
60
116
  });
117
+ }
61
118
  if (context?.browser !== browser) {
62
119
  context = await McpContext.from(browser, logger, {
63
120
  experimentalDevToolsDebugging: devtools,
64
121
  experimentalIncludeAllPages: args.experimentalIncludeAllPages,
65
122
  });
123
+ // Initialize WebMCP tool hub for dynamic tool registration
124
+ const toolHub = new WebMCPToolHub(server, context);
125
+ context.setToolHub(toolHub);
126
+ logger('WebMCPToolHub initialized for dynamic tool registration');
66
127
  }
67
128
  return context;
68
129
  }
130
+ /**
131
+ * Log security disclaimers to stderr.
132
+ *
133
+ * Warns users that browser content is exposed to MCP clients.
134
+ */
69
135
  const logDisclaimers = () => {
70
136
  console.error(`chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect,
71
137
  debug, and modify any data in the browser or DevTools.
72
138
  Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`);
73
139
  };
140
+ /**
141
+ * Mutex to serialize tool execution and prevent concurrent modifications.
142
+ */
74
143
  const toolMutex = new Mutex();
144
+ /**
145
+ * Register a tool with the MCP server.
146
+ *
147
+ * Handles category-based filtering (emulation, performance, network)
148
+ * and wraps the handler with context initialization and error handling.
149
+ *
150
+ * @param tool - Tool definition to register.
151
+ */
75
152
  function registerTool(tool) {
76
153
  if (tool.annotations.category === ToolCategory.EMULATION &&
77
154
  args.categoryEmulation === false) {
@@ -107,7 +184,10 @@ function registerTool(tool) {
107
184
  }
108
185
  catch (err) {
109
186
  logger(`${tool.name} error:`, err, err?.stack);
110
- const errorText = err && 'message' in err ? err.message : String(err);
187
+ let errorText = err && 'message' in err ? err.message : String(err);
188
+ if ('cause' in err && err.cause) {
189
+ errorText += `\nCause: ${err.cause.message}`;
190
+ }
111
191
  return {
112
192
  content: [
113
193
  {
@@ -27,8 +27,8 @@ export function registerPrompts(server) {
27
27
  1. **Write the Tool**: I'll ask you to create a WebMCP tool in my codebase using @mcp-b/global
28
28
  2. **Hot Reload**: My dev server will automatically reload with the new tool
29
29
  3. **Navigate**: Use navigate_page to open my dev server (e.g., http://localhost:3000)
30
- 4. **Discover**: Use list_webmcp_tools to see if the tool appears
31
- 5. **Test**: Use call_webmcp_tool to test the tool with sample inputs
30
+ 4. **Discover**: Use diff_webmcp_tools to see registered tools (shown with callable names like webmcp_localhost_3000_page0_my_tool)
31
+ 5. **Test**: Call the tool directly by its prefixed name (e.g., webmcp_localhost_3000_page0_my_tool)
32
32
  6. **Iterate**: If something is wrong, fix the code and repeat
33
33
 
34
34
  ## Tool Registration Pattern
@@ -92,9 +92,9 @@ What would you like to build? Describe the tool you need and I'll help you imple
92
92
  ## Test Plan
93
93
 
94
94
  1. Navigate to ${url}
95
- 2. Use list_webmcp_tools to discover available tools
95
+ 2. Use diff_webmcp_tools to discover registered tools (shown with callable names like webmcp_localhost_3000_page0_tool_name)
96
96
  3. ${toolNameInstruction}
97
- 4. Test the tool with various inputs:
97
+ 4. Call the tool directly by its prefixed name and test with various inputs:
98
98
  - Valid inputs (happy path)
99
99
  - Edge cases (empty strings, nulls, etc.)
100
100
  - Invalid inputs (wrong types, missing required fields)
@@ -138,7 +138,7 @@ ${urlInstruction}
138
138
  - Errors loading @mcp-b/global
139
139
  - Tool registration errors
140
140
  - Any JavaScript errors
141
- 3. **Test WebMCP**: Try list_webmcp_tools to see if connection works
141
+ 3. **Test WebMCP**: Try diff_webmcp_tools to see if connection works
142
142
  4. **Verify registration**: If no tools appear, check if the code properly imports '@mcp-b/global' and calls registerTool
143
143
 
144
144
  ## Common Issues
@@ -10,7 +10,7 @@ export { hideBin } from 'yargs/helpers';
10
10
  export { default as debug } from 'debug';
11
11
  export { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
- export { SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
13
+ export { SetLevelRequestSchema, ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
14
14
  export { z as zod } from 'zod';
15
15
  export { Locator, PredefinedNetworkConditions, CDPSessionEvent, } from 'puppeteer-core';
16
16
  export { default as puppeteer } from 'puppeteer-core';