chrome-devtools-mcp-canpoint 0.1.0__tar.gz

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.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: chrome-devtools-mcp-canpoint
3
+ Version: 0.1.0
4
+ Summary: Session-local Chrome launcher for Chrome DevTools MCP workflows.
5
+ Author: whqp
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/whqp/chrome-devtools-mcp-canpoint
8
+ Project-URL: Issues, https://github.com/whqp/chrome-devtools-mcp-canpoint/issues
9
+ Keywords: mcp,chrome,devtools,browser,automation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Debuggers
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # chrome-devtools-mcp-canpoint
25
+
26
+ Session-local Chrome launcher for Chrome DevTools MCP workflows.
27
+
28
+ This wrapper starts one dedicated Chrome process per MCP server process. Each
29
+ session gets its own random remote debugging port and a generated user data
30
+ directory under the current working directory, so multiple agent sessions do not
31
+ fight over port `9222` or a shared Chrome profile.
32
+
33
+ ## Usage
34
+
35
+ Run it directly with `uvx`:
36
+
37
+ ```powershell
38
+ uvx chrome-devtools-mcp-canpoint -- <downstream-mcp-command>
39
+ ```
40
+
41
+ Example:
42
+
43
+ ```powershell
44
+ uvx chrome-devtools-mcp-canpoint -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
45
+ ```
46
+
47
+ If Chrome is not installed in the default Windows location, pass it explicitly:
48
+
49
+ ```powershell
50
+ uvx chrome-devtools-mcp-canpoint --chrome-path "D:\Apps\Chrome\chrome.exe" -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
51
+ ```
52
+
53
+ ## Codex MCP Config
54
+
55
+ After publishing to PyPI, configure Codex like this:
56
+
57
+ ```toml
58
+ [mcp_servers.chrome-devtools]
59
+ command = "uvx"
60
+ args = [
61
+ "chrome-devtools-mcp-canpoint",
62
+ "--",
63
+ "npx",
64
+ "-y",
65
+ "chrome-devtools-mcp@latest",
66
+ "--browser-url={browser_url}",
67
+ "--no-usage-statistics"
68
+ ]
69
+ startup_timeout_sec = 60
70
+ ```
71
+
72
+ ## Session Profile Directory
73
+
74
+ By default, generated Chrome profiles are created under:
75
+
76
+ ```text
77
+ <current-working-directory>/.chrome-mcp-sessions/<uuid>
78
+ ```
79
+
80
+ Override the parent directory with `--session-root`:
81
+
82
+ ```powershell
83
+ uvx chrome-devtools-mcp-canpoint --session-root .\.chrome-mcp-sessions -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
84
+ ```
85
+
86
+ Use `--user-data-dir` only when you want an exact profile directory instead of a
87
+ generated per-session subdirectory.
88
+
89
+ ## Environment Passed to the Downstream MCP
90
+
91
+ The downstream command receives these variables:
92
+
93
+ - `CHROME_DEVTOOLS_URL`: `http://127.0.0.1:<random-port>`
94
+ - `BROWSER_URL`: same value as `CHROME_DEVTOOLS_URL`
95
+ - `CHROME_REMOTE_DEBUGGING_PORT`: selected port
96
+ - `CHROME_USER_DATA_DIR`: session profile path
97
+
98
+ Configure the real Chrome MCP package to use one of these values as its browser
99
+ endpoint. This wrapper also expands placeholders in downstream command
100
+ arguments:
101
+
102
+ - `{browser_url}` or `{devtools_url}`: `http://127.0.0.1:<random-port>`
103
+ - `{port}`: selected port
104
+ - `{user_data_dir}`: generated profile path
105
+
106
+ ## Cleanup
107
+
108
+ When the downstream MCP exits, this wrapper terminates only the Chrome process it
109
+ started. Temporary profiles are deleted by default.
110
+
111
+ Use `--keep-profile` when debugging:
112
+
113
+ ```powershell
114
+ uvx chrome-devtools-mcp-canpoint --keep-profile -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
115
+ ```
116
+
117
+ ## Options
118
+
119
+ ```powershell
120
+ uvx chrome-devtools-mcp-canpoint --help
121
+ ```
122
+
123
+ Useful options:
124
+
125
+ - `--chrome-path`: path to `chrome.exe`
126
+ - `--session-root`: parent directory for generated project-local profiles
127
+ - `--user-data-dir`: explicit profile directory instead of a generated one
128
+ - `--keep-profile`: leave the temporary profile on disk
129
+ - `--headless`: start Chrome with `--headless=new`
130
+ - `--chrome-arg`: pass extra arguments to Chrome, repeatable
131
+ - `--devtools-timeout`: seconds to wait for `/json/version`
132
+
133
+ ## Development
134
+
135
+ Use the project virtual environment for local development:
136
+
137
+ ```powershell
138
+ .\.venv\Scripts\python.exe -m pip install -e .
139
+ .\.venv\Scripts\python.exe -m unittest discover -v
140
+ .\.venv\Scripts\chrome-devtools-mcp-canpoint.exe --help
141
+ ```
142
+
143
+ Build distributions:
144
+
145
+ ```powershell
146
+ .\.venv\Scripts\python.exe -m build
147
+ ```
@@ -0,0 +1,124 @@
1
+ # chrome-devtools-mcp-canpoint
2
+
3
+ Session-local Chrome launcher for Chrome DevTools MCP workflows.
4
+
5
+ This wrapper starts one dedicated Chrome process per MCP server process. Each
6
+ session gets its own random remote debugging port and a generated user data
7
+ directory under the current working directory, so multiple agent sessions do not
8
+ fight over port `9222` or a shared Chrome profile.
9
+
10
+ ## Usage
11
+
12
+ Run it directly with `uvx`:
13
+
14
+ ```powershell
15
+ uvx chrome-devtools-mcp-canpoint -- <downstream-mcp-command>
16
+ ```
17
+
18
+ Example:
19
+
20
+ ```powershell
21
+ uvx chrome-devtools-mcp-canpoint -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
22
+ ```
23
+
24
+ If Chrome is not installed in the default Windows location, pass it explicitly:
25
+
26
+ ```powershell
27
+ uvx chrome-devtools-mcp-canpoint --chrome-path "D:\Apps\Chrome\chrome.exe" -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
28
+ ```
29
+
30
+ ## Codex MCP Config
31
+
32
+ After publishing to PyPI, configure Codex like this:
33
+
34
+ ```toml
35
+ [mcp_servers.chrome-devtools]
36
+ command = "uvx"
37
+ args = [
38
+ "chrome-devtools-mcp-canpoint",
39
+ "--",
40
+ "npx",
41
+ "-y",
42
+ "chrome-devtools-mcp@latest",
43
+ "--browser-url={browser_url}",
44
+ "--no-usage-statistics"
45
+ ]
46
+ startup_timeout_sec = 60
47
+ ```
48
+
49
+ ## Session Profile Directory
50
+
51
+ By default, generated Chrome profiles are created under:
52
+
53
+ ```text
54
+ <current-working-directory>/.chrome-mcp-sessions/<uuid>
55
+ ```
56
+
57
+ Override the parent directory with `--session-root`:
58
+
59
+ ```powershell
60
+ uvx chrome-devtools-mcp-canpoint --session-root .\.chrome-mcp-sessions -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
61
+ ```
62
+
63
+ Use `--user-data-dir` only when you want an exact profile directory instead of a
64
+ generated per-session subdirectory.
65
+
66
+ ## Environment Passed to the Downstream MCP
67
+
68
+ The downstream command receives these variables:
69
+
70
+ - `CHROME_DEVTOOLS_URL`: `http://127.0.0.1:<random-port>`
71
+ - `BROWSER_URL`: same value as `CHROME_DEVTOOLS_URL`
72
+ - `CHROME_REMOTE_DEBUGGING_PORT`: selected port
73
+ - `CHROME_USER_DATA_DIR`: session profile path
74
+
75
+ Configure the real Chrome MCP package to use one of these values as its browser
76
+ endpoint. This wrapper also expands placeholders in downstream command
77
+ arguments:
78
+
79
+ - `{browser_url}` or `{devtools_url}`: `http://127.0.0.1:<random-port>`
80
+ - `{port}`: selected port
81
+ - `{user_data_dir}`: generated profile path
82
+
83
+ ## Cleanup
84
+
85
+ When the downstream MCP exits, this wrapper terminates only the Chrome process it
86
+ started. Temporary profiles are deleted by default.
87
+
88
+ Use `--keep-profile` when debugging:
89
+
90
+ ```powershell
91
+ uvx chrome-devtools-mcp-canpoint --keep-profile -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
92
+ ```
93
+
94
+ ## Options
95
+
96
+ ```powershell
97
+ uvx chrome-devtools-mcp-canpoint --help
98
+ ```
99
+
100
+ Useful options:
101
+
102
+ - `--chrome-path`: path to `chrome.exe`
103
+ - `--session-root`: parent directory for generated project-local profiles
104
+ - `--user-data-dir`: explicit profile directory instead of a generated one
105
+ - `--keep-profile`: leave the temporary profile on disk
106
+ - `--headless`: start Chrome with `--headless=new`
107
+ - `--chrome-arg`: pass extra arguments to Chrome, repeatable
108
+ - `--devtools-timeout`: seconds to wait for `/json/version`
109
+
110
+ ## Development
111
+
112
+ Use the project virtual environment for local development:
113
+
114
+ ```powershell
115
+ .\.venv\Scripts\python.exe -m pip install -e .
116
+ .\.venv\Scripts\python.exe -m unittest discover -v
117
+ .\.venv\Scripts\chrome-devtools-mcp-canpoint.exe --help
118
+ ```
119
+
120
+ Build distributions:
121
+
122
+ ```powershell
123
+ .\.venv\Scripts\python.exe -m build
124
+ ```
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chrome-devtools-mcp-canpoint"
7
+ version = "0.1.0"
8
+ description = "Session-local Chrome launcher for Chrome DevTools MCP workflows."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "whqp" }
14
+ ]
15
+ keywords = ["mcp", "chrome", "devtools", "browser", "automation"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Debuggers",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/whqp/chrome-devtools-mcp-canpoint"
33
+ Issues = "https://github.com/whqp/chrome-devtools-mcp-canpoint/issues"
34
+
35
+ [project.scripts]
36
+ chrome-devtools-mcp-canpoint = "chrome_devtools_mcp_canpoint.cli:main"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.pytest.ini_options]
42
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Session-local Chrome launcher for Chrome DevTools MCP workflows."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import atexit
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ import uuid
14
+ import urllib.error
15
+ import urllib.request
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Mapping, Sequence
19
+
20
+
21
+ DEFAULT_CHROME_PATHS = (
22
+ Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
23
+ Path(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
24
+ )
25
+ DEFAULT_SESSION_ROOT_NAME = ".chrome-mcp-sessions"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ChromeSessionConfig:
30
+ chrome_path: Path
31
+ port: int
32
+ user_data_dir: Path
33
+ headless: bool
34
+ extra_args: tuple[str, ...]
35
+
36
+
37
+ def find_free_port(host: str = "127.0.0.1") -> int:
38
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
39
+ sock.bind((host, 0))
40
+ return int(sock.getsockname()[1])
41
+
42
+
43
+ def devtools_url(port: int) -> str:
44
+ return f"http://127.0.0.1:{port}"
45
+
46
+
47
+ def build_chrome_args(config: ChromeSessionConfig) -> list[str]:
48
+ args = [
49
+ str(config.chrome_path),
50
+ f"--remote-debugging-port={config.port}",
51
+ f"--user-data-dir={config.user_data_dir}",
52
+ "--no-first-run",
53
+ "--no-default-browser-check",
54
+ "--disable-background-networking",
55
+ "--disable-sync",
56
+ ]
57
+ if config.headless:
58
+ args.append("--headless=new")
59
+ args.extend(config.extra_args)
60
+ return args
61
+
62
+
63
+ def downstream_env(base_env: Mapping[str, str], port: int, user_data_dir: Path) -> dict[str, str]:
64
+ env = dict(base_env)
65
+ url = devtools_url(port)
66
+ env.update(
67
+ {
68
+ "CHROME_DEVTOOLS_URL": url,
69
+ "CHROME_REMOTE_DEBUGGING_PORT": str(port),
70
+ "CHROME_USER_DATA_DIR": str(user_data_dir),
71
+ "BROWSER_URL": url,
72
+ }
73
+ )
74
+ return env
75
+
76
+
77
+ def expand_downstream_command(
78
+ command: Sequence[str], port: int, user_data_dir: Path
79
+ ) -> list[str]:
80
+ replacements = {
81
+ "browser_url": devtools_url(port),
82
+ "devtools_url": devtools_url(port),
83
+ "port": str(port),
84
+ "user_data_dir": str(user_data_dir),
85
+ }
86
+ return [arg.format(**replacements) for arg in command]
87
+
88
+
89
+ def resolve_session_root(value: str | None) -> Path:
90
+ if value:
91
+ return Path(value).resolve()
92
+ return (Path.cwd() / DEFAULT_SESSION_ROOT_NAME).resolve()
93
+
94
+
95
+ def resolve_chrome_path(value: str | None) -> Path:
96
+ candidates: list[Path] = []
97
+ if value:
98
+ candidates.append(Path(value))
99
+ if os.environ.get("CHROME_PATH"):
100
+ candidates.append(Path(os.environ["CHROME_PATH"]))
101
+ candidates.extend(DEFAULT_CHROME_PATHS)
102
+
103
+ for candidate in candidates:
104
+ if candidate.exists():
105
+ return candidate
106
+
107
+ raise FileNotFoundError(
108
+ "Chrome executable not found. Pass --chrome-path or set CHROME_PATH."
109
+ )
110
+
111
+
112
+ def wait_for_devtools(port: int, timeout_seconds: float) -> None:
113
+ deadline = time.monotonic() + timeout_seconds
114
+ endpoint = f"{devtools_url(port)}/json/version"
115
+ last_error: Exception | None = None
116
+
117
+ while time.monotonic() < deadline:
118
+ try:
119
+ with urllib.request.urlopen(endpoint, timeout=1) as response:
120
+ if response.status == 200:
121
+ return
122
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
123
+ last_error = exc
124
+ time.sleep(0.1)
125
+
126
+ message = f"Timed out waiting for Chrome DevTools at {endpoint}"
127
+ if last_error:
128
+ message = f"{message}: {last_error}"
129
+ raise TimeoutError(message)
130
+
131
+
132
+ def bridge_stream(source, target) -> None:
133
+ source_fd = source.fileno()
134
+ target_fd = target.fileno()
135
+ try:
136
+ while True:
137
+ chunk = os.read(source_fd, 64 * 1024)
138
+ if not chunk:
139
+ break
140
+ os.write(target_fd, chunk)
141
+ finally:
142
+ try:
143
+ target.close()
144
+ except OSError:
145
+ pass
146
+
147
+
148
+ def terminate_process(process: subprocess.Popen[bytes], timeout_seconds: float = 5) -> None:
149
+ if process.poll() is not None:
150
+ return
151
+ if os.name == "nt":
152
+ subprocess.run(
153
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
154
+ stdout=subprocess.DEVNULL,
155
+ stderr=subprocess.DEVNULL,
156
+ check=False,
157
+ )
158
+ try:
159
+ process.wait(timeout=timeout_seconds)
160
+ except subprocess.TimeoutExpired:
161
+ process.kill()
162
+ process.wait(timeout=timeout_seconds)
163
+ return
164
+ process.terminate()
165
+ try:
166
+ process.wait(timeout=timeout_seconds)
167
+ except subprocess.TimeoutExpired:
168
+ process.kill()
169
+ process.wait(timeout=timeout_seconds)
170
+
171
+
172
+ def terminate_chrome_profile_processes(user_data_dir: Path) -> None:
173
+ if os.name != "nt":
174
+ return
175
+ powershell_path = (
176
+ Path(os.environ.get("SystemRoot", r"C:\Windows"))
177
+ / "System32"
178
+ / "WindowsPowerShell"
179
+ / "v1.0"
180
+ / "powershell.exe"
181
+ )
182
+ if not powershell_path.exists():
183
+ return
184
+ profile_marker = str(user_data_dir)
185
+ command = (
186
+ "Get-CimInstance Win32_Process -Filter \"name = 'chrome.exe'\" | "
187
+ "Where-Object { $_.CommandLine -like ('*' + $env:CHROME_MCP_PROFILE_MARKER + '*') } | "
188
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
189
+ )
190
+ env = dict(os.environ)
191
+ env["CHROME_MCP_PROFILE_MARKER"] = profile_marker
192
+ subprocess.run(
193
+ [str(powershell_path), "-NoProfile", "-Command", command],
194
+ stdout=subprocess.DEVNULL,
195
+ stderr=subprocess.DEVNULL,
196
+ env=env,
197
+ check=False,
198
+ )
199
+
200
+
201
+ def remove_directory_with_retries(path: Path, attempts: int = 20, delay_seconds: float = 0.25) -> None:
202
+ for attempt in range(attempts):
203
+ shutil.rmtree(path, ignore_errors=True)
204
+ if not path.exists():
205
+ return
206
+ if attempt < attempts - 1:
207
+ time.sleep(delay_seconds)
208
+
209
+
210
+ def run_downstream(command: Sequence[str], env: Mapping[str, str]) -> int:
211
+ downstream = subprocess.Popen(
212
+ list(command),
213
+ stdin=subprocess.PIPE,
214
+ stdout=subprocess.PIPE,
215
+ stderr=None,
216
+ env=dict(env),
217
+ )
218
+
219
+ stdin_thread = threading.Thread(
220
+ target=bridge_stream,
221
+ args=(sys.stdin.buffer, downstream.stdin),
222
+ daemon=True,
223
+ )
224
+ stdout_thread = threading.Thread(
225
+ target=bridge_stream,
226
+ args=(downstream.stdout, sys.stdout.buffer),
227
+ daemon=True,
228
+ )
229
+
230
+ def stop_downstream_when_input_closes() -> None:
231
+ stdin_thread.join()
232
+ if downstream.poll() is None:
233
+ time.sleep(0.5)
234
+ if downstream.poll() is None:
235
+ terminate_process(downstream)
236
+
237
+ stdin_thread.start()
238
+ stdout_thread.start()
239
+ input_watcher = threading.Thread(target=stop_downstream_when_input_closes, daemon=True)
240
+ input_watcher.start()
241
+
242
+ try:
243
+ return downstream.wait()
244
+ except KeyboardInterrupt:
245
+ terminate_process(downstream)
246
+ return 130
247
+ finally:
248
+ stdout_thread.join(timeout=1)
249
+
250
+
251
+ def create_parser() -> argparse.ArgumentParser:
252
+ parser = argparse.ArgumentParser(
253
+ description="Start a session-local Chrome instance and run a downstream MCP command."
254
+ )
255
+ parser.add_argument(
256
+ "--chrome-path",
257
+ help="Path to chrome.exe. Defaults to CHROME_PATH or common Windows install paths.",
258
+ )
259
+ parser.add_argument(
260
+ "--user-data-dir",
261
+ help="Exact profile directory to use. Overrides --session-root.",
262
+ )
263
+ parser.add_argument(
264
+ "--session-root",
265
+ default=None,
266
+ help="Directory for generated session profiles. Defaults to .chrome-mcp-sessions in the current working directory.",
267
+ )
268
+ parser.add_argument(
269
+ "--keep-profile",
270
+ action="store_true",
271
+ help="Do not delete the session profile after the downstream command exits.",
272
+ )
273
+ parser.add_argument(
274
+ "--headless",
275
+ action="store_true",
276
+ help="Start Chrome in headless mode.",
277
+ )
278
+ parser.add_argument(
279
+ "--devtools-timeout",
280
+ type=float,
281
+ default=15.0,
282
+ help="Seconds to wait for Chrome DevTools to become ready.",
283
+ )
284
+ parser.add_argument(
285
+ "--chrome-arg",
286
+ action="append",
287
+ default=[],
288
+ help="Extra argument passed to Chrome. Repeat for multiple values.",
289
+ )
290
+ parser.add_argument(
291
+ "command",
292
+ nargs=argparse.REMAINDER,
293
+ help="Downstream MCP command. Prefix it with --, for example: -- npx chrome-devtools-mcp",
294
+ )
295
+ return parser
296
+
297
+
298
+ def normalize_command(command: Sequence[str]) -> list[str]:
299
+ normalized = list(command)
300
+ if normalized and normalized[0] == "--":
301
+ normalized = normalized[1:]
302
+ if not normalized:
303
+ raise ValueError("Missing downstream MCP command after --.")
304
+ return normalized
305
+
306
+
307
+ def main(argv: Sequence[str] | None = None) -> int:
308
+ parser = create_parser()
309
+ args = parser.parse_args(argv)
310
+
311
+ try:
312
+ command = normalize_command(args.command)
313
+ chrome_path = resolve_chrome_path(args.chrome_path)
314
+ except (FileNotFoundError, ValueError) as exc:
315
+ parser.error(str(exc))
316
+
317
+ port = find_free_port()
318
+ generated_session_dir = args.user_data_dir is None
319
+ user_data_dir = (
320
+ Path(args.user_data_dir)
321
+ if args.user_data_dir
322
+ else resolve_session_root(args.session_root) / uuid.uuid4().hex
323
+ )
324
+ user_data_dir.mkdir(parents=True, exist_ok=True)
325
+
326
+ config = ChromeSessionConfig(
327
+ chrome_path=chrome_path,
328
+ port=port,
329
+ user_data_dir=user_data_dir,
330
+ headless=args.headless,
331
+ extra_args=tuple(args.chrome_arg),
332
+ )
333
+
334
+ chrome = subprocess.Popen(
335
+ build_chrome_args(config),
336
+ stdin=subprocess.DEVNULL,
337
+ stdout=subprocess.DEVNULL,
338
+ stderr=subprocess.DEVNULL,
339
+ )
340
+
341
+ def cleanup() -> None:
342
+ terminate_process(chrome)
343
+ terminate_chrome_profile_processes(user_data_dir)
344
+ if generated_session_dir and not args.keep_profile:
345
+ remove_directory_with_retries(user_data_dir)
346
+
347
+ atexit.register(cleanup)
348
+
349
+ previous_sigterm = signal.getsignal(signal.SIGTERM)
350
+
351
+ def handle_sigterm(signum, frame) -> None:
352
+ cleanup()
353
+ if callable(previous_sigterm):
354
+ previous_sigterm(signum, frame)
355
+ raise SystemExit(143)
356
+
357
+ signal.signal(signal.SIGTERM, handle_sigterm)
358
+
359
+ try:
360
+ wait_for_devtools(port, args.devtools_timeout)
361
+ env = downstream_env(os.environ, port, user_data_dir)
362
+ expanded_command = expand_downstream_command(command, port, user_data_dir)
363
+ return run_downstream(expanded_command, env)
364
+ except KeyboardInterrupt:
365
+ return 130
366
+ finally:
367
+ cleanup()
368
+
369
+
370
+ if __name__ == "__main__":
371
+ raise SystemExit(main())
372
+
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: chrome-devtools-mcp-canpoint
3
+ Version: 0.1.0
4
+ Summary: Session-local Chrome launcher for Chrome DevTools MCP workflows.
5
+ Author: whqp
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/whqp/chrome-devtools-mcp-canpoint
8
+ Project-URL: Issues, https://github.com/whqp/chrome-devtools-mcp-canpoint/issues
9
+ Keywords: mcp,chrome,devtools,browser,automation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Debuggers
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # chrome-devtools-mcp-canpoint
25
+
26
+ Session-local Chrome launcher for Chrome DevTools MCP workflows.
27
+
28
+ This wrapper starts one dedicated Chrome process per MCP server process. Each
29
+ session gets its own random remote debugging port and a generated user data
30
+ directory under the current working directory, so multiple agent sessions do not
31
+ fight over port `9222` or a shared Chrome profile.
32
+
33
+ ## Usage
34
+
35
+ Run it directly with `uvx`:
36
+
37
+ ```powershell
38
+ uvx chrome-devtools-mcp-canpoint -- <downstream-mcp-command>
39
+ ```
40
+
41
+ Example:
42
+
43
+ ```powershell
44
+ uvx chrome-devtools-mcp-canpoint -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
45
+ ```
46
+
47
+ If Chrome is not installed in the default Windows location, pass it explicitly:
48
+
49
+ ```powershell
50
+ uvx chrome-devtools-mcp-canpoint --chrome-path "D:\Apps\Chrome\chrome.exe" -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
51
+ ```
52
+
53
+ ## Codex MCP Config
54
+
55
+ After publishing to PyPI, configure Codex like this:
56
+
57
+ ```toml
58
+ [mcp_servers.chrome-devtools]
59
+ command = "uvx"
60
+ args = [
61
+ "chrome-devtools-mcp-canpoint",
62
+ "--",
63
+ "npx",
64
+ "-y",
65
+ "chrome-devtools-mcp@latest",
66
+ "--browser-url={browser_url}",
67
+ "--no-usage-statistics"
68
+ ]
69
+ startup_timeout_sec = 60
70
+ ```
71
+
72
+ ## Session Profile Directory
73
+
74
+ By default, generated Chrome profiles are created under:
75
+
76
+ ```text
77
+ <current-working-directory>/.chrome-mcp-sessions/<uuid>
78
+ ```
79
+
80
+ Override the parent directory with `--session-root`:
81
+
82
+ ```powershell
83
+ uvx chrome-devtools-mcp-canpoint --session-root .\.chrome-mcp-sessions -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
84
+ ```
85
+
86
+ Use `--user-data-dir` only when you want an exact profile directory instead of a
87
+ generated per-session subdirectory.
88
+
89
+ ## Environment Passed to the Downstream MCP
90
+
91
+ The downstream command receives these variables:
92
+
93
+ - `CHROME_DEVTOOLS_URL`: `http://127.0.0.1:<random-port>`
94
+ - `BROWSER_URL`: same value as `CHROME_DEVTOOLS_URL`
95
+ - `CHROME_REMOTE_DEBUGGING_PORT`: selected port
96
+ - `CHROME_USER_DATA_DIR`: session profile path
97
+
98
+ Configure the real Chrome MCP package to use one of these values as its browser
99
+ endpoint. This wrapper also expands placeholders in downstream command
100
+ arguments:
101
+
102
+ - `{browser_url}` or `{devtools_url}`: `http://127.0.0.1:<random-port>`
103
+ - `{port}`: selected port
104
+ - `{user_data_dir}`: generated profile path
105
+
106
+ ## Cleanup
107
+
108
+ When the downstream MCP exits, this wrapper terminates only the Chrome process it
109
+ started. Temporary profiles are deleted by default.
110
+
111
+ Use `--keep-profile` when debugging:
112
+
113
+ ```powershell
114
+ uvx chrome-devtools-mcp-canpoint --keep-profile -- npx -y chrome-devtools-mcp@latest --browser-url={browser_url}
115
+ ```
116
+
117
+ ## Options
118
+
119
+ ```powershell
120
+ uvx chrome-devtools-mcp-canpoint --help
121
+ ```
122
+
123
+ Useful options:
124
+
125
+ - `--chrome-path`: path to `chrome.exe`
126
+ - `--session-root`: parent directory for generated project-local profiles
127
+ - `--user-data-dir`: explicit profile directory instead of a generated one
128
+ - `--keep-profile`: leave the temporary profile on disk
129
+ - `--headless`: start Chrome with `--headless=new`
130
+ - `--chrome-arg`: pass extra arguments to Chrome, repeatable
131
+ - `--devtools-timeout`: seconds to wait for `/json/version`
132
+
133
+ ## Development
134
+
135
+ Use the project virtual environment for local development:
136
+
137
+ ```powershell
138
+ .\.venv\Scripts\python.exe -m pip install -e .
139
+ .\.venv\Scripts\python.exe -m unittest discover -v
140
+ .\.venv\Scripts\chrome-devtools-mcp-canpoint.exe --help
141
+ ```
142
+
143
+ Build distributions:
144
+
145
+ ```powershell
146
+ .\.venv\Scripts\python.exe -m build
147
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/chrome_devtools_mcp_canpoint/__init__.py
4
+ src/chrome_devtools_mcp_canpoint/cli.py
5
+ src/chrome_devtools_mcp_canpoint.egg-info/PKG-INFO
6
+ src/chrome_devtools_mcp_canpoint.egg-info/SOURCES.txt
7
+ src/chrome_devtools_mcp_canpoint.egg-info/dependency_links.txt
8
+ src/chrome_devtools_mcp_canpoint.egg-info/entry_points.txt
9
+ src/chrome_devtools_mcp_canpoint.egg-info/top_level.txt
10
+ tests/test_main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chrome-devtools-mcp-canpoint = chrome_devtools_mcp_canpoint.cli:main
@@ -0,0 +1,147 @@
1
+ import os
2
+ import sys
3
+ import threading
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
9
+
10
+ from chrome_devtools_mcp_canpoint import cli as main
11
+
12
+
13
+ class WrapperHelpersTest(unittest.TestCase):
14
+ def test_devtools_url_uses_loopback_and_port(self):
15
+ self.assertEqual(main.devtools_url(45678), "http://127.0.0.1:45678")
16
+
17
+ def test_downstream_env_injects_session_values(self):
18
+ env = main.downstream_env({"EXISTING": "1"}, 45678, Path(r"C:\Temp\profile"))
19
+
20
+ self.assertEqual(env["EXISTING"], "1")
21
+ self.assertEqual(env["CHROME_DEVTOOLS_URL"], "http://127.0.0.1:45678")
22
+ self.assertEqual(env["BROWSER_URL"], "http://127.0.0.1:45678")
23
+ self.assertEqual(env["CHROME_REMOTE_DEBUGGING_PORT"], "45678")
24
+ self.assertEqual(env["CHROME_USER_DATA_DIR"], r"C:\Temp\profile")
25
+
26
+ def test_build_chrome_args_includes_isolated_session_flags(self):
27
+ config = main.ChromeSessionConfig(
28
+ chrome_path=Path(r"C:\Chrome\chrome.exe"),
29
+ port=45678,
30
+ user_data_dir=Path(r"C:\Temp\profile"),
31
+ headless=True,
32
+ extra_args=("--window-size=1200,900",),
33
+ )
34
+
35
+ args = main.build_chrome_args(config)
36
+
37
+ self.assertEqual(args[0], r"C:\Chrome\chrome.exe")
38
+ self.assertIn("--remote-debugging-port=45678", args)
39
+ self.assertIn(r"--user-data-dir=C:\Temp\profile", args)
40
+ self.assertIn("--no-first-run", args)
41
+ self.assertIn("--no-default-browser-check", args)
42
+ self.assertIn("--headless=new", args)
43
+ self.assertIn("--window-size=1200,900", args)
44
+
45
+ def test_normalize_command_strips_separator(self):
46
+ self.assertEqual(
47
+ main.normalize_command(["--", "npx", "chrome-devtools-mcp"]),
48
+ ["npx", "chrome-devtools-mcp"],
49
+ )
50
+
51
+ def test_normalize_command_rejects_empty_command(self):
52
+ with self.assertRaises(ValueError):
53
+ main.normalize_command(["--"])
54
+
55
+ def test_expand_downstream_command_replaces_session_placeholders(self):
56
+ command = [
57
+ "npx",
58
+ "chrome-devtools-mcp",
59
+ "--browser-url={browser_url}",
60
+ "--profile={user_data_dir}",
61
+ "--port={port}",
62
+ ]
63
+
64
+ expanded = main.expand_downstream_command(command, 45678, Path(r"C:\Temp\profile"))
65
+
66
+ self.assertEqual(
67
+ expanded,
68
+ [
69
+ "npx",
70
+ "chrome-devtools-mcp",
71
+ "--browser-url=http://127.0.0.1:45678",
72
+ r"--profile=C:\Temp\profile",
73
+ "--port=45678",
74
+ ],
75
+ )
76
+
77
+ def test_resolve_chrome_path_uses_explicit_existing_path(self):
78
+ path = Path(os.environ["SystemRoot"]) / "System32" / "cmd.exe"
79
+
80
+ self.assertEqual(main.resolve_chrome_path(str(path)), path)
81
+
82
+ def test_resolve_session_root_defaults_to_current_working_directory(self):
83
+ self.assertEqual(
84
+ main.resolve_session_root(None),
85
+ (Path.cwd() / ".chrome-mcp-sessions").resolve(),
86
+ )
87
+
88
+ @unittest.skipUnless(os.name == "nt", "Windows-specific process tree cleanup")
89
+ def test_terminate_process_uses_taskkill_process_tree_on_windows(self):
90
+ process = mock.Mock()
91
+ process.poll.return_value = None
92
+ process.pid = 1234
93
+
94
+ with mock.patch.object(main.subprocess, "run") as run:
95
+ main.terminate_process(process)
96
+
97
+ run.assert_called_once_with(
98
+ ["taskkill", "/PID", "1234", "/T", "/F"],
99
+ stdout=main.subprocess.DEVNULL,
100
+ stderr=main.subprocess.DEVNULL,
101
+ check=False,
102
+ )
103
+ process.wait.assert_called_once()
104
+
105
+ def test_remove_directory_with_retries_stops_after_directory_is_gone(self):
106
+ path = mock.Mock()
107
+ path.exists.side_effect = [True, False]
108
+
109
+ with mock.patch.object(main.shutil, "rmtree") as rmtree, mock.patch.object(
110
+ main.time, "sleep"
111
+ ) as sleep:
112
+ main.remove_directory_with_retries(path, attempts=3, delay_seconds=0.01)
113
+
114
+ self.assertEqual(rmtree.call_count, 2)
115
+ sleep.assert_called_once_with(0.01)
116
+
117
+ def test_bridge_stream_forwards_small_message_before_eof(self):
118
+ source_read, source_write = os.pipe()
119
+ target_read, target_write = os.pipe()
120
+ with os.fdopen(source_read, "rb", buffering=0) as source, os.fdopen(
121
+ target_write, "wb", buffering=0
122
+ ) as target:
123
+ thread = threading.Thread(target=main.bridge_stream, args=(source, target))
124
+ thread.start()
125
+ message = b"Content-Length: 2\r\n\r\n{}"
126
+ os.write(source_write, message)
127
+ forwarded = os.read(target_read, len(message))
128
+ os.close(source_write)
129
+ thread.join(timeout=2)
130
+ os.close(target_read)
131
+
132
+ self.assertEqual(forwarded, message)
133
+
134
+ @unittest.skipUnless(os.name == "nt", "Windows-specific Chrome cleanup")
135
+ def test_terminate_chrome_profile_processes_filters_by_profile_path(self):
136
+ with mock.patch.object(main.subprocess, "run") as run:
137
+ main.terminate_chrome_profile_processes(Path(r"C:\Temp\profile"))
138
+
139
+ args, kwargs = run.call_args
140
+ self.assertTrue(args[0][0].lower().endswith(r"powershell.exe"))
141
+ self.assertEqual(args[0][1:3], ["-NoProfile", "-Command"])
142
+ self.assertEqual(kwargs["env"]["CHROME_MCP_PROFILE_MARKER"], r"C:\Temp\profile")
143
+ self.assertFalse(kwargs["check"])
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()