iflow-mcp_janspoerer-mcp_browser_use 0.1.0__py3-none-any.whl
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.
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/METADATA +26 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/RECORD +50 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/licenses/LICENSE +201 -0
- iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/top_level.txt +1 -0
- mcp_browser_use/__init__.py +2 -0
- mcp_browser_use/__main__.py +1347 -0
- mcp_browser_use/actions/__init__.py +1 -0
- mcp_browser_use/actions/elements.py +173 -0
- mcp_browser_use/actions/extraction.py +864 -0
- mcp_browser_use/actions/keyboard.py +43 -0
- mcp_browser_use/actions/navigation.py +73 -0
- mcp_browser_use/actions/screenshots.py +85 -0
- mcp_browser_use/browser/__init__.py +1 -0
- mcp_browser_use/browser/chrome.py +150 -0
- mcp_browser_use/browser/chrome_executable.py +204 -0
- mcp_browser_use/browser/chrome_launcher.py +330 -0
- mcp_browser_use/browser/chrome_process.py +104 -0
- mcp_browser_use/browser/devtools.py +230 -0
- mcp_browser_use/browser/driver.py +322 -0
- mcp_browser_use/browser/process.py +133 -0
- mcp_browser_use/cleaners.py +530 -0
- mcp_browser_use/config/__init__.py +30 -0
- mcp_browser_use/config/environment.py +155 -0
- mcp_browser_use/config/paths.py +97 -0
- mcp_browser_use/constants.py +68 -0
- mcp_browser_use/context.py +150 -0
- mcp_browser_use/context_pack.py +85 -0
- mcp_browser_use/decorators/__init__.py +13 -0
- mcp_browser_use/decorators/ensure.py +84 -0
- mcp_browser_use/decorators/envelope.py +83 -0
- mcp_browser_use/decorators/locking.py +172 -0
- mcp_browser_use/helpers.py +173 -0
- mcp_browser_use/helpers_context.py +261 -0
- mcp_browser_use/locking/__init__.py +1 -0
- mcp_browser_use/locking/action_lock.py +190 -0
- mcp_browser_use/locking/file_mutex.py +139 -0
- mcp_browser_use/locking/window_registry.py +178 -0
- mcp_browser_use/tools/__init__.py +59 -0
- mcp_browser_use/tools/browser_management.py +260 -0
- mcp_browser_use/tools/debugging.py +195 -0
- mcp_browser_use/tools/extraction.py +58 -0
- mcp_browser_use/tools/interaction.py +323 -0
- mcp_browser_use/tools/navigation.py +84 -0
- mcp_browser_use/tools/screenshots.py +116 -0
- mcp_browser_use/utils/__init__.py +1 -0
- mcp_browser_use/utils/diagnostics.py +85 -0
- mcp_browser_use/utils/html_utils.py +118 -0
- mcp_browser_use/utils/retry.py +57 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Chrome launch orchestration and command building."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Tuple, Optional
|
|
10
|
+
|
|
11
|
+
from .chrome_executable import get_chrome_binary_for_platform
|
|
12
|
+
from .chrome_process import find_chrome_by_port, is_chrome_running_with_userdata
|
|
13
|
+
|
|
14
|
+
# Import from sibling modules
|
|
15
|
+
from .devtools import devtools_active_port_from_file, is_debugger_listening
|
|
16
|
+
from .process import write_rendezvous, get_free_port
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_chrome_command(
|
|
23
|
+
binary: str,
|
|
24
|
+
port: int,
|
|
25
|
+
user_data_dir: str,
|
|
26
|
+
profile_name: str,
|
|
27
|
+
) -> list[str]:
|
|
28
|
+
"""
|
|
29
|
+
Build Chrome command-line arguments for debugging.
|
|
30
|
+
|
|
31
|
+
Consolidates duplicated command building from 2 locations in start_or_attach_chrome_from_env.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
binary: Path to Chrome executable
|
|
35
|
+
port: Remote debugging port
|
|
36
|
+
user_data_dir: Chrome user data directory
|
|
37
|
+
profile_name: Chrome profile name
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
list[str]: Command-line arguments for Chrome
|
|
41
|
+
"""
|
|
42
|
+
headless = os.getenv("MCP_HEADLESS", "0").strip()
|
|
43
|
+
is_headless = headless in ("1", "true", "True", "yes", "Yes")
|
|
44
|
+
|
|
45
|
+
cmd = [
|
|
46
|
+
binary,
|
|
47
|
+
f"--remote-debugging-port={port}",
|
|
48
|
+
f"--user-data-dir={user_data_dir}",
|
|
49
|
+
f"--profile-directory={profile_name}",
|
|
50
|
+
"--no-first-run",
|
|
51
|
+
"--no-default-browser-check",
|
|
52
|
+
"--new-window",
|
|
53
|
+
"--disable-features=ProcessPerSite",
|
|
54
|
+
"--disable-gpu",
|
|
55
|
+
"--disable-dev-shm-usage",
|
|
56
|
+
"--disable-software-rasterizer",
|
|
57
|
+
"about:blank",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
if is_headless:
|
|
61
|
+
cmd.append("--headless=new")
|
|
62
|
+
|
|
63
|
+
# Load unpacked extensions from an external Extensions folder
|
|
64
|
+
# This prevents Chrome from auto-deleting extensions it doesn't recognize
|
|
65
|
+
enable_extensions = os.getenv("MCP_ENABLE_EXTENSIONS", "0").strip()
|
|
66
|
+
|
|
67
|
+
# Debug logging to file
|
|
68
|
+
debug_log_dir = Path(tempfile.gettempdir()) / "mcp_browser_logs"
|
|
69
|
+
debug_log_dir.mkdir(exist_ok=True)
|
|
70
|
+
debug_log_file = debug_log_dir / "extension_loading_debug.log"
|
|
71
|
+
|
|
72
|
+
with open(debug_log_file, "a") as f:
|
|
73
|
+
f.write(f"\n=== Extension Loading Debug {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
|
|
74
|
+
f.write(f"MCP_ENABLE_EXTENSIONS env var: {enable_extensions}\n")
|
|
75
|
+
f.write(f"user_data_dir: {user_data_dir}\n")
|
|
76
|
+
f.write(f"profile_name: {profile_name}\n")
|
|
77
|
+
|
|
78
|
+
if enable_extensions in ("1", "true", "True", "yes", "Yes"):
|
|
79
|
+
# Load extensions from dedicated MCPExtensions folder
|
|
80
|
+
# This avoids conflicts with Chrome's internal extension management
|
|
81
|
+
if platform.system() == "Windows":
|
|
82
|
+
extensions_base_path = Path(os.path.expandvars(r"%USERPROFILE%\MCPExtensions"))
|
|
83
|
+
else:
|
|
84
|
+
extensions_base_path = Path(os.path.expanduser("~/MCPExtensions"))
|
|
85
|
+
|
|
86
|
+
with open(debug_log_file, "a") as f:
|
|
87
|
+
f.write(f"extensions_base_path: {extensions_base_path}\n")
|
|
88
|
+
f.write(f"extensions_base_path.exists(): {extensions_base_path.exists()}\n")
|
|
89
|
+
|
|
90
|
+
extension_paths = []
|
|
91
|
+
if extensions_base_path.exists():
|
|
92
|
+
# Scan all subdirectories in MCPExtensions for valid extensions
|
|
93
|
+
for extension_dir in extensions_base_path.iterdir():
|
|
94
|
+
if extension_dir.is_dir() and (extension_dir / "manifest.json").exists():
|
|
95
|
+
extension_paths.append(str(extension_dir))
|
|
96
|
+
|
|
97
|
+
with open(debug_log_file, "a") as f:
|
|
98
|
+
f.write(f"Found {len(extension_paths)} extension(s)\n")
|
|
99
|
+
for path in extension_paths:
|
|
100
|
+
f.write(f" - {path}\n")
|
|
101
|
+
|
|
102
|
+
if extension_paths:
|
|
103
|
+
# Join all extension paths with comma
|
|
104
|
+
load_ext_arg = f"--load-extension={','.join(extension_paths)}"
|
|
105
|
+
cmd.append(load_ext_arg)
|
|
106
|
+
logger.info(f"Loading {len(extension_paths)} extension(s): {extension_paths}")
|
|
107
|
+
|
|
108
|
+
with open(debug_log_file, "a") as f:
|
|
109
|
+
f.write(f"Added to command: {load_ext_arg}\n")
|
|
110
|
+
f.write(f"Full command: {' '.join(cmd)}\n")
|
|
111
|
+
|
|
112
|
+
return cmd
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def launch_chrome_process(
|
|
116
|
+
cmd: list[str],
|
|
117
|
+
port: int,
|
|
118
|
+
) -> subprocess.Popen:
|
|
119
|
+
"""
|
|
120
|
+
Launch Chrome process and verify it started.
|
|
121
|
+
|
|
122
|
+
Consolidates duplicated launch logic from 2 locations in start_or_attach_chrome_from_env.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
cmd: Command-line arguments for Chrome
|
|
126
|
+
port: Remote debugging port (used for logging)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
subprocess.Popen: Chrome process
|
|
130
|
+
|
|
131
|
+
Note:
|
|
132
|
+
Does not raise if process exits immediately; caller should check proc.poll()
|
|
133
|
+
"""
|
|
134
|
+
# Create error log file
|
|
135
|
+
log_dir = Path(tempfile.gettempdir()) / "mcp_browser_logs"
|
|
136
|
+
log_dir.mkdir(exist_ok=True)
|
|
137
|
+
error_log = log_dir / f"chrome_debug_{port}.log"
|
|
138
|
+
|
|
139
|
+
# Launch process
|
|
140
|
+
if platform.system() == "Windows":
|
|
141
|
+
proc = subprocess.Popen(
|
|
142
|
+
cmd,
|
|
143
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
144
|
+
stdin=subprocess.DEVNULL,
|
|
145
|
+
stderr=subprocess.DEVNULL,
|
|
146
|
+
stdout=subprocess.DEVNULL
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
proc = subprocess.Popen(
|
|
150
|
+
cmd,
|
|
151
|
+
stdin=subprocess.DEVNULL,
|
|
152
|
+
stderr=subprocess.DEVNULL,
|
|
153
|
+
stdout=subprocess.DEVNULL
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Verify Chrome started
|
|
157
|
+
time.sleep(2.0)
|
|
158
|
+
if proc.poll() is not None:
|
|
159
|
+
try:
|
|
160
|
+
with open(error_log, "r") as log:
|
|
161
|
+
error_content = log.read()
|
|
162
|
+
logger.error(f"Chrome failed to start. Exit code: {proc.returncode}. Log: {error_content}")
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
return proc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def wait_for_devtools_ready(
|
|
170
|
+
host: str,
|
|
171
|
+
port: int,
|
|
172
|
+
user_data_dir: str,
|
|
173
|
+
timeout_iterations: int = 100,
|
|
174
|
+
) -> bool:
|
|
175
|
+
"""
|
|
176
|
+
Wait for DevTools endpoint to become available.
|
|
177
|
+
|
|
178
|
+
Consolidates duplicated waiting logic from 2 locations in start_or_attach_chrome_from_env.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
host: Debugger host (typically "127.0.0.1")
|
|
182
|
+
port: Remote debugging port
|
|
183
|
+
user_data_dir: Chrome user data directory (for error messages)
|
|
184
|
+
timeout_iterations: Number of 0.1s iterations to wait
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
bool: True if endpoint is ready
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
RuntimeError: If endpoint never appears with helpful diagnostic message
|
|
191
|
+
"""
|
|
192
|
+
for _ in range(timeout_iterations):
|
|
193
|
+
if is_debugger_listening(host, port):
|
|
194
|
+
return True
|
|
195
|
+
time.sleep(0.1)
|
|
196
|
+
|
|
197
|
+
# If endpoint never appeared, provide helpful error
|
|
198
|
+
if is_chrome_running_with_userdata(user_data_dir):
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
"DevTools endpoint did not appear. Likely causes:\n"
|
|
201
|
+
" - Chrome refused remote debugging because the user-data-dir is a default channel directory.\n"
|
|
202
|
+
" - Another Chrome instance (started without --remote-debugging-port) is holding this user-data-dir.\n"
|
|
203
|
+
"Actions:\n"
|
|
204
|
+
f" - Use a separate automation dir (e.g., '{Path(user_data_dir).parent}/Chrome Beta MCP'), "
|
|
205
|
+
"optionally seeded from your profile, and try again.\n"
|
|
206
|
+
" - Or ensure all Chrome/Chrome Beta processes are fully quit before starting."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
raise RuntimeError(f"Failed to start Chrome with remote debugging on {port}.")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def try_attach_existing_chrome(
|
|
213
|
+
config: dict,
|
|
214
|
+
host: str,
|
|
215
|
+
) -> Optional[Tuple[str, int, None]]:
|
|
216
|
+
"""
|
|
217
|
+
Try to attach to existing Chrome instance via DevToolsActivePort.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
config: Configuration dict with user_data_dir
|
|
221
|
+
host: Debugger host (typically "127.0.0.1")
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Optional[Tuple[str, int, None]]: (host, port, None) if successful, None otherwise
|
|
225
|
+
"""
|
|
226
|
+
user_data_dir = config["user_data_dir"]
|
|
227
|
+
|
|
228
|
+
existing_port = devtools_active_port_from_file(user_data_dir)
|
|
229
|
+
if existing_port and is_debugger_listening(host, existing_port):
|
|
230
|
+
chrome_proc = find_chrome_by_port(existing_port)
|
|
231
|
+
write_rendezvous(config, existing_port, chrome_proc.pid if chrome_proc else os.getpid())
|
|
232
|
+
return host, existing_port, None
|
|
233
|
+
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def launch_on_fixed_port(
|
|
238
|
+
config: dict,
|
|
239
|
+
host: str,
|
|
240
|
+
port: int,
|
|
241
|
+
) -> Tuple[str, int, Optional[subprocess.Popen]]:
|
|
242
|
+
"""
|
|
243
|
+
Launch Chrome on a fixed port or attach to existing instance.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
config: Configuration dict with user_data_dir, profile_name
|
|
247
|
+
host: Debugger host (typically "127.0.0.1")
|
|
248
|
+
port: Fixed port to use
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Tuple[str, int, Optional[subprocess.Popen]]: (host, port, proc) where proc is None if attached to existing
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
RuntimeError: If Chrome fails to start or DevTools endpoint doesn't appear
|
|
255
|
+
"""
|
|
256
|
+
user_data_dir = config["user_data_dir"]
|
|
257
|
+
profile_name = config["profile_name"]
|
|
258
|
+
|
|
259
|
+
# Check if profile is already debuggable on a different port
|
|
260
|
+
existing_port = devtools_active_port_from_file(user_data_dir)
|
|
261
|
+
if existing_port and existing_port != port and is_debugger_listening(host, existing_port):
|
|
262
|
+
chrome_proc = find_chrome_by_port(existing_port)
|
|
263
|
+
write_rendezvous(config, existing_port, chrome_proc.pid if chrome_proc else os.getpid())
|
|
264
|
+
return host, existing_port, None
|
|
265
|
+
|
|
266
|
+
# Check if already listening on target port
|
|
267
|
+
if is_debugger_listening(host, port):
|
|
268
|
+
chrome_proc = find_chrome_by_port(port)
|
|
269
|
+
write_rendezvous(config, port, chrome_proc.pid if chrome_proc else os.getpid())
|
|
270
|
+
return host, port, None
|
|
271
|
+
|
|
272
|
+
# Launch Chrome on fixed port
|
|
273
|
+
binary = get_chrome_binary_for_platform(config)
|
|
274
|
+
cmd = build_chrome_command(binary, port, user_data_dir, profile_name)
|
|
275
|
+
proc = launch_chrome_process(cmd, port)
|
|
276
|
+
|
|
277
|
+
# Wait for DevTools
|
|
278
|
+
if wait_for_devtools_ready(host, port, user_data_dir):
|
|
279
|
+
chrome_proc = find_chrome_by_port(port)
|
|
280
|
+
write_rendezvous(config, port, chrome_proc.pid if chrome_proc else proc.pid)
|
|
281
|
+
return host, port, proc
|
|
282
|
+
|
|
283
|
+
raise RuntimeError(f"Failed to start Chrome with remote debugging on {port}.")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def launch_on_dynamic_port(
|
|
287
|
+
config: dict,
|
|
288
|
+
host: str,
|
|
289
|
+
) -> Tuple[str, int, subprocess.Popen]:
|
|
290
|
+
"""
|
|
291
|
+
Launch Chrome on a dynamically assigned port.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
config: Configuration dict with user_data_dir, profile_name
|
|
295
|
+
host: Debugger host (typically "127.0.0.1")
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Tuple[str, int, subprocess.Popen]: (host, port, proc)
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
RuntimeError: If Chrome fails to start or DevTools endpoint doesn't appear
|
|
302
|
+
"""
|
|
303
|
+
user_data_dir = config["user_data_dir"]
|
|
304
|
+
profile_name = config["profile_name"]
|
|
305
|
+
|
|
306
|
+
port = get_free_port()
|
|
307
|
+
binary = get_chrome_binary_for_platform(config)
|
|
308
|
+
cmd = build_chrome_command(binary, port, user_data_dir, profile_name)
|
|
309
|
+
proc = launch_chrome_process(cmd, port)
|
|
310
|
+
|
|
311
|
+
# Wait for DevTools
|
|
312
|
+
if wait_for_devtools_ready(host, port, user_data_dir):
|
|
313
|
+
chrome_proc = find_chrome_by_port(port)
|
|
314
|
+
if chrome_proc:
|
|
315
|
+
write_rendezvous(config, port, chrome_proc.pid)
|
|
316
|
+
else:
|
|
317
|
+
write_rendezvous(config, port, proc.pid)
|
|
318
|
+
return host, port, proc
|
|
319
|
+
|
|
320
|
+
raise RuntimeError("Failed to start Chrome with remote debugging; endpoint never came up.")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
__all__ = [
|
|
324
|
+
'build_chrome_command',
|
|
325
|
+
'launch_chrome_process',
|
|
326
|
+
'wait_for_devtools_ready',
|
|
327
|
+
'try_attach_existing_chrome',
|
|
328
|
+
'launch_on_fixed_port',
|
|
329
|
+
'launch_on_dynamic_port',
|
|
330
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Chrome process discovery and management."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_chrome_running_with_userdata(user_data_dir: str) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Check if any Chrome process is running with the specified user-data-dir.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
user_data_dir: Path to Chrome user data directory
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
bool: True if a Chrome process is found with this user-data-dir
|
|
21
|
+
"""
|
|
22
|
+
for p in psutil.process_iter(["name", "cmdline"]):
|
|
23
|
+
try:
|
|
24
|
+
if not p.info["name"] or "chrome" not in p.info["name"].lower():
|
|
25
|
+
continue
|
|
26
|
+
cmd = p.info.get("cmdline") or []
|
|
27
|
+
if any((arg or "").startswith("--user-data-dir=") and (arg.split("=", 1)[1].strip('"') == user_data_dir)
|
|
28
|
+
for arg in cmd):
|
|
29
|
+
return True
|
|
30
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
31
|
+
continue
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_chrome_by_port(port: int) -> Optional[psutil.Process]:
|
|
36
|
+
"""
|
|
37
|
+
Find Chrome process listening on the specified debug port.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
port: Remote debugging port number
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Optional[psutil.Process]: Chrome process if found, None otherwise
|
|
44
|
+
"""
|
|
45
|
+
target = f"--remote-debugging-port={port}"
|
|
46
|
+
for p in psutil.process_iter(["name", "cmdline", "exe"]):
|
|
47
|
+
try:
|
|
48
|
+
if not p.info["name"]:
|
|
49
|
+
continue
|
|
50
|
+
if "chrome" not in p.info["name"].lower():
|
|
51
|
+
continue
|
|
52
|
+
cmd = p.info.get("cmdline") or []
|
|
53
|
+
if any(target in (arg or "") for arg in cmd):
|
|
54
|
+
return p
|
|
55
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
56
|
+
continue
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_chrome_by_userdata(user_data_dir: str) -> Optional[psutil.Process]:
|
|
61
|
+
"""
|
|
62
|
+
Find the first Chrome process using the specified user-data-dir.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
user_data_dir: Path to Chrome user data directory
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Optional[psutil.Process]: Chrome process if found, None otherwise
|
|
69
|
+
"""
|
|
70
|
+
for p in psutil.process_iter(["name", "cmdline"]):
|
|
71
|
+
try:
|
|
72
|
+
if not p.info["name"] or "chrome" not in p.info["name"].lower():
|
|
73
|
+
continue
|
|
74
|
+
cmd = p.info.get("cmdline") or []
|
|
75
|
+
if any((arg or "").startswith("--user-data-dir=") and
|
|
76
|
+
(arg.split("=", 1)[1].strip('"') == user_data_dir)
|
|
77
|
+
for arg in cmd):
|
|
78
|
+
return p
|
|
79
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
80
|
+
continue
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def wait_for_process_stable(proc: subprocess.Popen, timeout: float = 2.0) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Wait for process to stabilize and check if it's still running.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
proc: Subprocess to check
|
|
90
|
+
timeout: Time to wait in seconds
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if process is running, False if it exited
|
|
94
|
+
"""
|
|
95
|
+
time.sleep(timeout)
|
|
96
|
+
return proc.poll() is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
'is_chrome_running_with_userdata',
|
|
101
|
+
'find_chrome_by_port',
|
|
102
|
+
'find_chrome_by_userdata',
|
|
103
|
+
'wait_for_process_stable',
|
|
104
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""DevTools protocol operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
from ..context import get_context
|
|
14
|
+
from ..constants import ALLOW_ATTACH_ANY
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_devtools_active_port(user_data_dir: str) -> int | None:
|
|
18
|
+
"""Read debug port from DevToolsActivePort file."""
|
|
19
|
+
p = Path(user_data_dir) / "DevToolsActivePort"
|
|
20
|
+
if not p.exists():
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
first = p.read_text().splitlines()[0].strip()
|
|
24
|
+
return int(first)
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _same_dir(a: str, b: str) -> bool:
|
|
30
|
+
"""Check if two paths refer to the same directory."""
|
|
31
|
+
if not a or not b:
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
return Path(a).resolve() == Path(b).resolve()
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _devtools_user_data_dir(host: str, port: int, timeout: float = 1.5) -> str | None:
|
|
40
|
+
"""Get user data directory from DevTools protocol."""
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(f"http://{host}:{port}/json/version", timeout=timeout) as resp:
|
|
43
|
+
meta = json.load(resp)
|
|
44
|
+
return meta.get("userDataDir")
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _verify_port_matches_profile(host: str, port: int, expected_dir: str) -> bool:
|
|
50
|
+
"""Verify that a debug port belongs to the expected profile."""
|
|
51
|
+
actual = _devtools_user_data_dir(host, port)
|
|
52
|
+
return _same_dir(actual, expected_dir)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_debugger_listening(host: str, port: int, timeout: float = 3.0) -> bool:
|
|
56
|
+
"""Check if Chrome DevTools debugger is listening on a port."""
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(f"http://{host}:{port}/json/version", timeout=timeout) as resp:
|
|
59
|
+
return resp.status == 200
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def devtools_active_port_from_file(user_data_dir: str) -> Optional[int]:
|
|
65
|
+
"""
|
|
66
|
+
If Chrome is running this profile with remote debugging enabled,
|
|
67
|
+
it writes 'DevToolsActivePort' in the user-data-dir. Return that port if valid.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
p = Path(user_data_dir) / "DevToolsActivePort"
|
|
71
|
+
if not p.exists():
|
|
72
|
+
return None
|
|
73
|
+
lines = p.read_text().splitlines()
|
|
74
|
+
if not lines:
|
|
75
|
+
return None
|
|
76
|
+
first = lines[0].strip()
|
|
77
|
+
return int(first) if first.isdigit() else None
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ensure_debugger_ready(cfg: dict, max_wait_secs: float | None = None) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Ensure a debuggable Chrome is running for the configured user-data-dir,
|
|
85
|
+
set debugger host/port in context.
|
|
86
|
+
"""
|
|
87
|
+
from .process import _is_port_open
|
|
88
|
+
from .chrome import start_or_attach_chrome_from_env, _launch_chrome_with_debug
|
|
89
|
+
|
|
90
|
+
ctx = get_context()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
host, port, _ = start_or_attach_chrome_from_env(cfg)
|
|
94
|
+
if not (ALLOW_ATTACH_ANY or _verify_port_matches_profile(host, port, cfg["user_data_dir"])):
|
|
95
|
+
raise RuntimeError("DevTools port does not belong to the configured profile")
|
|
96
|
+
ctx.debugger_host = host
|
|
97
|
+
ctx.debugger_port = port
|
|
98
|
+
return
|
|
99
|
+
except Exception:
|
|
100
|
+
ctx.debugger_host = None
|
|
101
|
+
ctx.debugger_port = None
|
|
102
|
+
|
|
103
|
+
# Allow override by env; default to 10 seconds
|
|
104
|
+
try:
|
|
105
|
+
max_wait_secs = float(os.getenv("MCP_DEVTOOLS_MAX_WAIT_SECS", "10")) if max_wait_secs is None else float(max_wait_secs)
|
|
106
|
+
except Exception:
|
|
107
|
+
max_wait_secs = 10.0
|
|
108
|
+
|
|
109
|
+
udir = cfg["user_data_dir"]
|
|
110
|
+
env_port = os.getenv("CHROME_REMOTE_DEBUG_PORT")
|
|
111
|
+
try:
|
|
112
|
+
env_port = int(env_port) if env_port else None
|
|
113
|
+
except Exception:
|
|
114
|
+
env_port = None
|
|
115
|
+
|
|
116
|
+
# 1) If the profile already wrote DevToolsActivePort, try to attach to that.
|
|
117
|
+
file_port = _read_devtools_active_port(udir)
|
|
118
|
+
if file_port and _is_port_open("127.0.0.1", file_port):
|
|
119
|
+
ctx.debugger_host = "127.0.0.1"
|
|
120
|
+
ctx.debugger_port = file_port
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# 2) If allowed, attach to a known open port
|
|
124
|
+
if ALLOW_ATTACH_ANY:
|
|
125
|
+
for p in filter(None, [env_port, 9223]):
|
|
126
|
+
if _is_port_open("127.0.0.1", p):
|
|
127
|
+
ctx.debugger_host = "127.0.0.1"
|
|
128
|
+
ctx.debugger_port = p
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# 3) Launch our own debuggable Chrome
|
|
132
|
+
port = env_port or 9225
|
|
133
|
+
_launch_chrome_with_debug(cfg, port)
|
|
134
|
+
|
|
135
|
+
# Wait until Chrome writes the file OR the TCP port answers
|
|
136
|
+
t0 = time.time()
|
|
137
|
+
while time.time() - t0 < max_wait_secs:
|
|
138
|
+
p = _read_devtools_active_port(udir)
|
|
139
|
+
if (p and _is_port_open("127.0.0.1", p)) or _is_port_open("127.0.0.1", port):
|
|
140
|
+
ctx.debugger_host = "127.0.0.1"
|
|
141
|
+
ctx.debugger_port = p or port
|
|
142
|
+
return
|
|
143
|
+
time.sleep(0.1)
|
|
144
|
+
|
|
145
|
+
ctx.debugger_host = None
|
|
146
|
+
ctx.debugger_port = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _handle_for_target(driver, target_id: Optional[str]) -> Optional[str]:
|
|
150
|
+
"""Get window handle for a Chrome DevTools target ID."""
|
|
151
|
+
import time
|
|
152
|
+
|
|
153
|
+
if not target_id:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Fast path: Selenium handle suffix matches CDP targetId
|
|
157
|
+
for h in driver.window_handles:
|
|
158
|
+
try:
|
|
159
|
+
if h.endswith(target_id):
|
|
160
|
+
return h
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# Nudge Chrome to foreground that target, then retry
|
|
165
|
+
try:
|
|
166
|
+
driver.execute_cdp_cmd("Target.activateTarget", {"targetId": target_id})
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
for _ in range(20): # ~1s total
|
|
171
|
+
for h in driver.window_handles:
|
|
172
|
+
try:
|
|
173
|
+
if h.endswith(target_id):
|
|
174
|
+
return h
|
|
175
|
+
except Exception:
|
|
176
|
+
continue
|
|
177
|
+
time.sleep(0.05)
|
|
178
|
+
|
|
179
|
+
# Robust path: probe handles via CDP
|
|
180
|
+
current = driver.current_window_handle if driver.window_handles else None
|
|
181
|
+
try:
|
|
182
|
+
for h in driver.window_handles:
|
|
183
|
+
try:
|
|
184
|
+
driver.switch_to.window(h)
|
|
185
|
+
info = driver.execute_cdp_cmd("Target.getTargetInfo", {}) or {}
|
|
186
|
+
tid = (info.get("targetInfo") or {}).get("targetId") or info.get("targetId")
|
|
187
|
+
if tid == target_id:
|
|
188
|
+
return h
|
|
189
|
+
except Exception:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Last resort: enumerate all targets
|
|
193
|
+
try:
|
|
194
|
+
targets = driver.execute_cdp_cmd("Target.getTargets", {}) or {}
|
|
195
|
+
for ti in (targets.get("targetInfos") or []):
|
|
196
|
+
if ti.get("targetId") == target_id:
|
|
197
|
+
try:
|
|
198
|
+
driver.execute_cdp_cmd("Target.activateTarget", {"targetId": target_id})
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
# One last quick scan
|
|
202
|
+
for h in driver.window_handles:
|
|
203
|
+
try:
|
|
204
|
+
if h.endswith(target_id):
|
|
205
|
+
return h
|
|
206
|
+
except Exception:
|
|
207
|
+
continue
|
|
208
|
+
break
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
finally:
|
|
212
|
+
if current and current in getattr(driver, "window_handles", []):
|
|
213
|
+
try:
|
|
214
|
+
driver.switch_to.window(current)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = [
|
|
222
|
+
'_read_devtools_active_port',
|
|
223
|
+
'devtools_active_port_from_file',
|
|
224
|
+
'_devtools_user_data_dir',
|
|
225
|
+
'_verify_port_matches_profile',
|
|
226
|
+
'_same_dir',
|
|
227
|
+
'is_debugger_listening',
|
|
228
|
+
'_ensure_debugger_ready',
|
|
229
|
+
'_handle_for_target',
|
|
230
|
+
]
|