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,43 @@
|
|
|
1
|
+
"""Keyboard input and scrolling."""
|
|
2
|
+
|
|
3
|
+
from selenium.webdriver.common.keys import Keys
|
|
4
|
+
|
|
5
|
+
from ..context import get_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def send_keys(keys_string: str) -> dict:
|
|
9
|
+
"""Send keyboard input."""
|
|
10
|
+
ctx = get_context()
|
|
11
|
+
if not ctx.driver:
|
|
12
|
+
return {"ok": False, "error": "No driver available"}
|
|
13
|
+
try:
|
|
14
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
15
|
+
ActionChains(ctx.driver).send_keys(keys_string).perform()
|
|
16
|
+
return {"ok": True}
|
|
17
|
+
except Exception as e:
|
|
18
|
+
return {"ok": False, "error": str(e)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scroll(direction: str = "down", amount: int = 300) -> dict:
|
|
22
|
+
"""Scroll the page."""
|
|
23
|
+
ctx = get_context()
|
|
24
|
+
if not ctx.driver:
|
|
25
|
+
return {"ok": False, "error": "No driver available"}
|
|
26
|
+
try:
|
|
27
|
+
if direction == "down":
|
|
28
|
+
ctx.driver.execute_script(f"window.scrollBy(0, {amount});")
|
|
29
|
+
elif direction == "up":
|
|
30
|
+
ctx.driver.execute_script(f"window.scrollBy(0, -{amount});")
|
|
31
|
+
elif direction == "top":
|
|
32
|
+
ctx.driver.execute_script("window.scrollTo(0, 0);")
|
|
33
|
+
elif direction == "bottom":
|
|
34
|
+
ctx.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
|
35
|
+
return {"ok": True}
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return {"ok": False, "error": str(e)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
'send_keys',
|
|
42
|
+
'scroll',
|
|
43
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Navigation and page interaction."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
5
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
6
|
+
|
|
7
|
+
from ..context import get_context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _wait_document_ready(timeout: float = 10.0):
|
|
11
|
+
"""Wait for document to be ready."""
|
|
12
|
+
ctx = get_context()
|
|
13
|
+
if not ctx.driver:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
WebDriverWait(ctx.driver, timeout).until(
|
|
18
|
+
lambda d: d.execute_script("return document.readyState") in ("interactive", "complete")
|
|
19
|
+
)
|
|
20
|
+
except Exception:
|
|
21
|
+
# Not fatal
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def navigate_to_url(url: str) -> dict:
|
|
26
|
+
"""Navigate to URL."""
|
|
27
|
+
ctx = get_context()
|
|
28
|
+
if not ctx.driver:
|
|
29
|
+
return {"ok": False, "error": "No driver available"}
|
|
30
|
+
try:
|
|
31
|
+
ctx.driver.get(url)
|
|
32
|
+
_wait_document_ready()
|
|
33
|
+
return {"ok": True}
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return {"ok": False, "error": str(e)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wait_for_element(selector: str, timeout: float = 10.0) -> dict:
|
|
39
|
+
"""Wait for element to appear."""
|
|
40
|
+
ctx = get_context()
|
|
41
|
+
if not ctx.driver:
|
|
42
|
+
return {"ok": False, "error": "No driver available"}
|
|
43
|
+
try:
|
|
44
|
+
from selenium.webdriver.common.by import By
|
|
45
|
+
WebDriverWait(ctx.driver, timeout).until(
|
|
46
|
+
EC.presence_of_element_located((By.CSS_SELECTOR, selector))
|
|
47
|
+
)
|
|
48
|
+
return {"ok": True}
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return {"ok": False, "error": str(e)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_current_page_meta() -> dict:
|
|
54
|
+
"""Get current page metadata."""
|
|
55
|
+
ctx = get_context()
|
|
56
|
+
if not ctx.driver:
|
|
57
|
+
return {"ok": False, "error": "No driver available"}
|
|
58
|
+
try:
|
|
59
|
+
return {
|
|
60
|
+
"ok": True,
|
|
61
|
+
"url": ctx.driver.current_url,
|
|
62
|
+
"title": ctx.driver.title,
|
|
63
|
+
}
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return {"ok": False, "error": str(e)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
'_wait_document_ready',
|
|
70
|
+
'navigate_to_url',
|
|
71
|
+
'wait_for_element',
|
|
72
|
+
'get_current_page_meta',
|
|
73
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Screenshot and page snapshot functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import io
|
|
6
|
+
import base64
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..context import get_context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_page_snapshot() -> dict:
|
|
13
|
+
"""
|
|
14
|
+
Capture the raw page snapshot (no cleaning, no truncation).
|
|
15
|
+
Returns a dict: {"url": str|None, "title": str|None, "html": str}
|
|
16
|
+
"""
|
|
17
|
+
from .navigation import _wait_document_ready
|
|
18
|
+
|
|
19
|
+
ctx = get_context()
|
|
20
|
+
url = None
|
|
21
|
+
title = None
|
|
22
|
+
html = ""
|
|
23
|
+
try:
|
|
24
|
+
if ctx.driver is not None:
|
|
25
|
+
try:
|
|
26
|
+
ctx.driver.switch_to.default_content()
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
try:
|
|
30
|
+
url = ctx.driver.current_url
|
|
31
|
+
except Exception:
|
|
32
|
+
url = None
|
|
33
|
+
try:
|
|
34
|
+
title = ctx.driver.title
|
|
35
|
+
except Exception:
|
|
36
|
+
title = None
|
|
37
|
+
|
|
38
|
+
# Ensure DOM is ready, then apply configurable settle
|
|
39
|
+
try:
|
|
40
|
+
_wait_document_ready(timeout=5.0)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
try:
|
|
44
|
+
settle_ms = int(os.getenv("SNAPSHOT_SETTLE_MS", "200") or "0")
|
|
45
|
+
if settle_ms > 0:
|
|
46
|
+
time.sleep(settle_ms / 1000.0)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Prefer outerHTML; fall back to page_source
|
|
51
|
+
try:
|
|
52
|
+
html = ctx.driver.execute_script("return document.documentElement.outerHTML") or ""
|
|
53
|
+
if not html:
|
|
54
|
+
html = ctx.driver.page_source or ""
|
|
55
|
+
except Exception:
|
|
56
|
+
try:
|
|
57
|
+
html = ctx.driver.page_source or ""
|
|
58
|
+
except Exception:
|
|
59
|
+
html = ""
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return {"url": url, "title": title, "html": html}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def take_screenshot(filename: Optional[str] = None) -> dict:
|
|
66
|
+
"""Take a screenshot."""
|
|
67
|
+
ctx = get_context()
|
|
68
|
+
if not ctx.driver:
|
|
69
|
+
return {"ok": False, "error": "No driver available"}
|
|
70
|
+
try:
|
|
71
|
+
if filename:
|
|
72
|
+
ctx.driver.save_screenshot(filename)
|
|
73
|
+
return {"ok": True, "path": filename}
|
|
74
|
+
else:
|
|
75
|
+
png_data = ctx.driver.get_screenshot_as_png()
|
|
76
|
+
b64 = base64.b64encode(png_data).decode('utf-8')
|
|
77
|
+
return {"ok": True, "data": b64}
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return {"ok": False, "error": str(e)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
'_make_page_snapshot',
|
|
84
|
+
'take_screenshot',
|
|
85
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Browser management module."""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Chrome browser management - Main orchestration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Tuple, Optional
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
# Import from refactored modules
|
|
11
|
+
from .chrome_executable import validate_user_data_dir, get_chrome_binary_for_platform
|
|
12
|
+
from .chrome_launcher import (
|
|
13
|
+
try_attach_existing_chrome,
|
|
14
|
+
launch_on_fixed_port,
|
|
15
|
+
launch_on_dynamic_port,
|
|
16
|
+
build_chrome_command,
|
|
17
|
+
launch_chrome_process,
|
|
18
|
+
)
|
|
19
|
+
from .chrome_process import find_chrome_by_port
|
|
20
|
+
from .devtools import devtools_active_port_from_file, is_debugger_listening
|
|
21
|
+
from .process import read_rendezvous, write_rendezvous
|
|
22
|
+
from ..locking.file_mutex import acquire_start_lock, release_start_lock
|
|
23
|
+
from ..constants import START_LOCK_WAIT_SEC
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _launch_chrome_with_debug(cfg: dict, port: int) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Launch Chrome with remote debugging on a specific port.
|
|
33
|
+
|
|
34
|
+
This is a simple wrapper around the chrome_launcher functions,
|
|
35
|
+
used by devtools.py when it needs to launch Chrome directly.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
cfg: Configuration dict with user_data_dir, profile_name, chrome_path (optional)
|
|
39
|
+
port: Remote debugging port to use
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
RuntimeError: If Chrome fails to launch
|
|
43
|
+
"""
|
|
44
|
+
# Get Chrome binary
|
|
45
|
+
chrome_path = cfg.get("chrome_path")
|
|
46
|
+
if not chrome_path:
|
|
47
|
+
chrome_path = get_chrome_binary_for_platform()
|
|
48
|
+
|
|
49
|
+
# Build command
|
|
50
|
+
cmd = build_chrome_command(
|
|
51
|
+
binary=chrome_path,
|
|
52
|
+
port=port,
|
|
53
|
+
user_data_dir=cfg["user_data_dir"],
|
|
54
|
+
profile_name=cfg.get("profile_name", "Default"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Launch process
|
|
58
|
+
proc = launch_chrome_process(cmd, port)
|
|
59
|
+
|
|
60
|
+
# On Windows, Chrome's launcher process exits immediately after spawning background processes.
|
|
61
|
+
# This is normal behavior. Only check for immediate exit on non-Windows platforms.
|
|
62
|
+
if platform.system() != "Windows":
|
|
63
|
+
time.sleep(0.2) # Brief wait to check if process exits immediately
|
|
64
|
+
if proc.poll() is not None:
|
|
65
|
+
raise RuntimeError(f"Chrome process exited immediately with code {proc.returncode}")
|
|
66
|
+
|
|
67
|
+
logger.info(f"Launched Chrome on port {port}, pid={proc.pid}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def start_or_attach_chrome_from_env(config: dict) -> Tuple[str, int, Optional[psutil.Process]]:
|
|
71
|
+
"""
|
|
72
|
+
Start or attach to Chrome with remote debugging enabled.
|
|
73
|
+
|
|
74
|
+
This is the main orchestration function that coordinates:
|
|
75
|
+
1. Directory validation
|
|
76
|
+
2. Attempting to attach to existing Chrome
|
|
77
|
+
3. Launching on fixed or dynamic port
|
|
78
|
+
4. Rendezvous file management
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
config: Configuration dict with user_data_dir, profile_name, fixed_port (optional)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tuple of (host, port, proc) where proc is None if attached to existing Chrome
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
RuntimeError: If Chrome fails to start or validation fails
|
|
88
|
+
"""
|
|
89
|
+
user_data_dir = config["user_data_dir"]
|
|
90
|
+
fixed_port = config.get("fixed_port")
|
|
91
|
+
host = "127.0.0.1"
|
|
92
|
+
|
|
93
|
+
# Ensure directory exists
|
|
94
|
+
Path(user_data_dir).mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
# Validate directory
|
|
97
|
+
validate_user_data_dir(user_data_dir)
|
|
98
|
+
|
|
99
|
+
# Try to attach to existing Chrome first (if no fixed port specified)
|
|
100
|
+
if not fixed_port:
|
|
101
|
+
result = try_attach_existing_chrome(config, host)
|
|
102
|
+
if result:
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
# Fixed port path
|
|
106
|
+
if fixed_port:
|
|
107
|
+
return launch_on_fixed_port(config, host, fixed_port)
|
|
108
|
+
|
|
109
|
+
# Rendezvous path (multi-process coordination)
|
|
110
|
+
port, pid = read_rendezvous(config)
|
|
111
|
+
if port:
|
|
112
|
+
return host, port, None
|
|
113
|
+
|
|
114
|
+
got_lock = acquire_start_lock(config, timeout_sec=START_LOCK_WAIT_SEC)
|
|
115
|
+
try:
|
|
116
|
+
if not got_lock:
|
|
117
|
+
# Wait for rendezvous by the process that got the lock
|
|
118
|
+
for _ in range(50):
|
|
119
|
+
port, pid = read_rendezvous(config)
|
|
120
|
+
if port:
|
|
121
|
+
return host, port, None
|
|
122
|
+
|
|
123
|
+
# Also try attaching via DevToolsActivePort if it appears
|
|
124
|
+
p2 = devtools_active_port_from_file(user_data_dir)
|
|
125
|
+
if p2 and is_debugger_listening(host, p2):
|
|
126
|
+
chrome_proc = find_chrome_by_port(p2)
|
|
127
|
+
write_rendezvous(config, p2, chrome_proc.pid if chrome_proc else os.getpid())
|
|
128
|
+
return host, p2, None
|
|
129
|
+
|
|
130
|
+
time.sleep(0.1)
|
|
131
|
+
|
|
132
|
+
raise RuntimeError("Timeout acquiring start lock for Chrome rendezvous.")
|
|
133
|
+
|
|
134
|
+
# Inside lock: recheck rendezvous
|
|
135
|
+
port, pid = read_rendezvous(config)
|
|
136
|
+
if port:
|
|
137
|
+
return host, port, None
|
|
138
|
+
|
|
139
|
+
# Launch Chrome on dynamic port
|
|
140
|
+
return launch_on_dynamic_port(config, host)
|
|
141
|
+
|
|
142
|
+
finally:
|
|
143
|
+
if got_lock:
|
|
144
|
+
release_start_lock(config)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
'start_or_attach_chrome_from_env',
|
|
149
|
+
'_launch_chrome_with_debug',
|
|
150
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Chrome executable resolution, version detection, and directory validation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_chrome_executable(cfg: dict) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Resolve Chrome executable path from config or platform defaults.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
cfg: Configuration dict with optional chrome_path, chrome_executable, etc.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str: Path to Chrome executable
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
FileNotFoundError: If Chrome executable cannot be found
|
|
25
|
+
"""
|
|
26
|
+
if cfg.get("chrome_path"):
|
|
27
|
+
return cfg["chrome_path"]
|
|
28
|
+
|
|
29
|
+
# Try config keys first
|
|
30
|
+
candidates = [
|
|
31
|
+
cfg.get("chrome_executable"),
|
|
32
|
+
cfg.get("chrome_binary"),
|
|
33
|
+
cfg.get("chrome_executable_path"),
|
|
34
|
+
os.getenv("CHROME_EXECUTABLE_PATH"),
|
|
35
|
+
]
|
|
36
|
+
# Common macOS fallbacks
|
|
37
|
+
defaults = [
|
|
38
|
+
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
39
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
40
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
41
|
+
]
|
|
42
|
+
for p in candidates + defaults:
|
|
43
|
+
if p and os.path.exists(p):
|
|
44
|
+
return p
|
|
45
|
+
raise FileNotFoundError(
|
|
46
|
+
"Chrome executable not found. Set CHROME_EXECUTABLE_PATH to the full binary path, "
|
|
47
|
+
"e.g. /Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_chrome_binary_for_platform(config: dict) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Get platform-specific Chrome binary path.
|
|
54
|
+
|
|
55
|
+
Tries to find Chrome binary based on the current platform.
|
|
56
|
+
Returns a reasonable default if not found.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: Configuration dict with optional chrome_path
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: Path to Chrome binary or "chrome" as fallback
|
|
63
|
+
"""
|
|
64
|
+
if config.get("chrome_path"):
|
|
65
|
+
return config["chrome_path"]
|
|
66
|
+
|
|
67
|
+
system = platform.system()
|
|
68
|
+
candidates = []
|
|
69
|
+
|
|
70
|
+
if system == "Windows":
|
|
71
|
+
candidates = [
|
|
72
|
+
r"C:\Program Files\Google\Chrome Beta\Application\chrome.exe",
|
|
73
|
+
r"C:\Program Files (x86)\Google\Chrome Beta\Application\chrome.exe",
|
|
74
|
+
"chrome",
|
|
75
|
+
]
|
|
76
|
+
elif system == "Darwin":
|
|
77
|
+
candidates = ["/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome"]
|
|
78
|
+
else:
|
|
79
|
+
candidates = ["google-chrome", "chrome", "chromium", "chromium-browser"]
|
|
80
|
+
|
|
81
|
+
for c in candidates:
|
|
82
|
+
if os.path.isfile(c) or shutil.which(c):
|
|
83
|
+
return c
|
|
84
|
+
|
|
85
|
+
return "chrome"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_chrome_version() -> str:
|
|
89
|
+
"""
|
|
90
|
+
Get Chrome version string from registry or executable.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
str: Chrome version string or error message
|
|
94
|
+
"""
|
|
95
|
+
system = platform.system()
|
|
96
|
+
try:
|
|
97
|
+
if system == "Windows":
|
|
98
|
+
try:
|
|
99
|
+
import winreg
|
|
100
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Google\Chrome\BLBeacon") as key:
|
|
101
|
+
version, _ = winreg.QueryValueEx(key, "version")
|
|
102
|
+
return f"Google Chrome {version}"
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
# Fallbacks
|
|
106
|
+
for candidate in [
|
|
107
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
108
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
109
|
+
"chrome",
|
|
110
|
+
]:
|
|
111
|
+
try:
|
|
112
|
+
path = candidate if os.path.isfile(candidate) else shutil.which(candidate)
|
|
113
|
+
if path:
|
|
114
|
+
out = subprocess.check_output([path, "--version"], stderr=subprocess.STDOUT).decode().strip()
|
|
115
|
+
return out
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
return "Error fetching Chrome version: chrome binary not found"
|
|
119
|
+
elif system == "Darwin":
|
|
120
|
+
path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
121
|
+
out = subprocess.check_output([path, "--version"], stderr=subprocess.STDOUT).decode().strip()
|
|
122
|
+
return out
|
|
123
|
+
else:
|
|
124
|
+
for candidate in ["google-chrome", "chrome", "chromium", "chromium-browser"]:
|
|
125
|
+
try:
|
|
126
|
+
path = shutil.which(candidate)
|
|
127
|
+
if path:
|
|
128
|
+
out = subprocess.check_output([path, "--version"], stderr=subprocess.STDOUT).decode().strip()
|
|
129
|
+
return out
|
|
130
|
+
except Exception:
|
|
131
|
+
continue
|
|
132
|
+
return "Error fetching Chrome version: chrome binary not found"
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return f"Error fetching Chrome version: {e}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_default_user_data_dir(user_data_dir: str) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Return True if user_data_dir is one of Chrome's default roots (where DevTools is refused).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
user_data_dir: Path to Chrome user data directory
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
bool: True if this is a default Chrome directory
|
|
146
|
+
"""
|
|
147
|
+
p = Path(user_data_dir).expanduser().resolve()
|
|
148
|
+
system = platform.system()
|
|
149
|
+
defaults = []
|
|
150
|
+
|
|
151
|
+
if system == "Darwin":
|
|
152
|
+
defaults = [
|
|
153
|
+
Path.home() / "Library/Application Support/Google/Chrome",
|
|
154
|
+
Path.home() / "Library/Application Support/Google/Chrome Beta",
|
|
155
|
+
Path.home() / "Library/Application Support/Google/Chrome Canary",
|
|
156
|
+
]
|
|
157
|
+
elif system == "Windows":
|
|
158
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
159
|
+
if local:
|
|
160
|
+
base = Path(local) / "Google"
|
|
161
|
+
defaults = [
|
|
162
|
+
base / "Chrome" / "User Data",
|
|
163
|
+
base / "Chrome Beta" / "User Data",
|
|
164
|
+
base / "Chrome SxS" / "User Data", # Canary
|
|
165
|
+
]
|
|
166
|
+
else: # Linux
|
|
167
|
+
home = Path.home()
|
|
168
|
+
defaults = [
|
|
169
|
+
home / ".config/google-chrome",
|
|
170
|
+
home / ".config/google-chrome-beta",
|
|
171
|
+
home / ".config/google-chrome-unstable", # Canary
|
|
172
|
+
home / ".config/chromium",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
return any(p == d for d in defaults)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_user_data_dir(user_data_dir: str) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Validate user_data_dir and raise if it's a default directory.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
user_data_dir: Path to Chrome user data directory
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
RuntimeError: If user_data_dir is a default Chrome directory
|
|
187
|
+
"""
|
|
188
|
+
if is_default_user_data_dir(user_data_dir):
|
|
189
|
+
if os.getenv("MCP_ALLOW_DEFAULT_USER_DATA_DIR", "0") != "1":
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"Remote debugging is disabled on Chrome's default user-data directories.\n"
|
|
192
|
+
f"Set *_PROFILE_USER_DATA_DIR to a separate path (e.g., '{Path(user_data_dir).parent}/Chrome Beta MCP'), "
|
|
193
|
+
"optionally seed it from your existing profile, then retry.\n"
|
|
194
|
+
"To override (not recommended), set MCP_ALLOW_DEFAULT_USER_DATA_DIR=1."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
'resolve_chrome_executable',
|
|
200
|
+
'get_chrome_binary_for_platform',
|
|
201
|
+
'get_chrome_version',
|
|
202
|
+
'is_default_user_data_dir',
|
|
203
|
+
'validate_user_data_dir',
|
|
204
|
+
]
|