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,155 @@
|
|
|
1
|
+
"""Environment configuration and validation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_env_config() -> dict:
|
|
13
|
+
"""
|
|
14
|
+
Read environment variables and validate required ones.
|
|
15
|
+
|
|
16
|
+
Prioritizes Chrome Beta over Chrome Canary over Chrome. This is to free the Chrome instance. Chrome is likely
|
|
17
|
+
used by the user already. It is easier to separate the executables. If a user already has the Chrome executable open,
|
|
18
|
+
the MCP will not work properly as the Chrome DevTool Debug mode will not open when Chrome is already open in normal mode.
|
|
19
|
+
We prioritize Chrome Beta because it is more stable than Canary.
|
|
20
|
+
|
|
21
|
+
Required: Either CHROME_PROFILE_USER_DATA_DIR, BETA_PROFILE_USER_DATA_DIR, or CANARY_PROFILE_USER_DATA_DIR
|
|
22
|
+
Optional: CHROME_PROFILE_NAME (default 'Default')
|
|
23
|
+
CHROME_EXECUTABLE_PATH
|
|
24
|
+
BETA_EXECUTABLE_PATH (overrides CHROME_EXECUTABLE_PATH)
|
|
25
|
+
CANARY_EXECUTABLE_PATH (overrides BETA and CHROME)
|
|
26
|
+
CHROME_REMOTE_DEBUG_PORT
|
|
27
|
+
|
|
28
|
+
If BETA_EXECUTABLE_PATH is set, expects:
|
|
29
|
+
BETA_PROFILE_USER_DATA_DIR
|
|
30
|
+
BETA_PROFILE_NAME
|
|
31
|
+
If CANARY_EXECUTABLE_PATH is set, expects:
|
|
32
|
+
CANARY_PROFILE_USER_DATA_DIR
|
|
33
|
+
CANARY_PROFILE_NAME
|
|
34
|
+
"""
|
|
35
|
+
# Base (generic) config
|
|
36
|
+
user_data_dir = (os.getenv("CHROME_PROFILE_USER_DATA_DIR") or "").strip()
|
|
37
|
+
if not user_data_dir and not os.getenv("BETA_PROFILE_USER_DATA_DIR") and not os.getenv("CANARY_PROFILE_USER_DATA_DIR"):
|
|
38
|
+
raise EnvironmentError("CHROME_PROFILE_USER_DATA_DIR is required.")
|
|
39
|
+
|
|
40
|
+
profile_name = (os.getenv("CHROME_PROFILE_NAME") or "Default").strip() or "Default"
|
|
41
|
+
chrome_path = (os.getenv("CHROME_EXECUTABLE_PATH") or "").strip() or None
|
|
42
|
+
|
|
43
|
+
# Prefer Beta > Canary > Generic Chrome
|
|
44
|
+
canary_path = (os.getenv("CANARY_EXECUTABLE_PATH") or "").strip()
|
|
45
|
+
if canary_path:
|
|
46
|
+
chrome_path = canary_path
|
|
47
|
+
user_data_dir = (os.getenv("CANARY_PROFILE_USER_DATA_DIR") or "").strip()
|
|
48
|
+
profile_name = (os.getenv("CANARY_PROFILE_NAME") or "").strip() or "Default"
|
|
49
|
+
if not user_data_dir:
|
|
50
|
+
raise EnvironmentError("CANARY_PROFILE_USER_DATA_DIR is required when CANARY_EXECUTABLE_PATH is set.")
|
|
51
|
+
|
|
52
|
+
beta_path = (os.getenv("BETA_EXECUTABLE_PATH") or "").strip()
|
|
53
|
+
if beta_path:
|
|
54
|
+
chrome_path = beta_path
|
|
55
|
+
user_data_dir = (os.getenv("BETA_PROFILE_USER_DATA_DIR") or "").strip()
|
|
56
|
+
profile_name = (os.getenv("BETA_PROFILE_NAME") or "").strip() or "Default"
|
|
57
|
+
if not user_data_dir:
|
|
58
|
+
raise EnvironmentError("BETA_PROFILE_USER_DATA_DIR is required when BETA_EXECUTABLE_PATH is set.")
|
|
59
|
+
|
|
60
|
+
fixed_port_env = (os.getenv("CHROME_REMOTE_DEBUG_PORT") or "").strip()
|
|
61
|
+
fixed_port = int(fixed_port_env) if fixed_port_env.isdigit() else None
|
|
62
|
+
|
|
63
|
+
if not user_data_dir:
|
|
64
|
+
raise EnvironmentError(
|
|
65
|
+
"No user_data_dir selected. Set CHROME_PROFILE_USER_DATA_DIR, or provide "
|
|
66
|
+
"BETA_EXECUTABLE_PATH + BETA_PROFILE_USER_DATA_DIR (or CANARY_* equivalents)."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"user_data_dir": user_data_dir,
|
|
71
|
+
"profile_name": profile_name,
|
|
72
|
+
"chrome_path": chrome_path,
|
|
73
|
+
"fixed_port": fixed_port,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def profile_key(config: Optional[dict] = None) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Stable key used by cross-process locks, based on absolute user_data_dir + profile_name.
|
|
80
|
+
- Hard-fails if CHROME_PROFILE_USER_DATA_DIR is missing/blank.
|
|
81
|
+
- If CHROME_PROFILE_STRICT=1 and the directory doesn't exist, hard-fail.
|
|
82
|
+
Otherwise we allow Chrome to create it and we normalize the path for stability.
|
|
83
|
+
"""
|
|
84
|
+
if config is None:
|
|
85
|
+
config = get_env_config()
|
|
86
|
+
|
|
87
|
+
user_data_dir = (config.get("user_data_dir") or "").strip()
|
|
88
|
+
profile_name = (config.get("profile_name") or "Default").strip() or "Default"
|
|
89
|
+
|
|
90
|
+
if not user_data_dir:
|
|
91
|
+
raise EnvironmentError("CHROME_PROFILE_USER_DATA_DIR is required and cannot be empty.")
|
|
92
|
+
|
|
93
|
+
strict = os.getenv("CHROME_PROFILE_STRICT", "0") == "1"
|
|
94
|
+
p = Path(user_data_dir)
|
|
95
|
+
if strict and not p.exists():
|
|
96
|
+
raise FileNotFoundError(f"user_data_dir does not exist: {p}")
|
|
97
|
+
|
|
98
|
+
# Normalize to a stable absolute string
|
|
99
|
+
try:
|
|
100
|
+
user_data_dir = str(p.resolve())
|
|
101
|
+
except Exception:
|
|
102
|
+
user_data_dir = str(p.absolute())
|
|
103
|
+
|
|
104
|
+
raw = f"{user_data_dir}|{profile_name}"
|
|
105
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_default_user_data_dir(user_data_dir: str) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Check if the given user_data_dir matches the platform's default Chrome profile location.
|
|
111
|
+
|
|
112
|
+
This is used to determine whether we can safely kill all Chrome processes or if we should
|
|
113
|
+
be more conservative (to avoid killing the user's main browser).
|
|
114
|
+
|
|
115
|
+
Returns True if user_data_dir matches a known default Chrome profile path for the current platform.
|
|
116
|
+
"""
|
|
117
|
+
import platform
|
|
118
|
+
|
|
119
|
+
system = platform.system()
|
|
120
|
+
user_data_dir_resolved = str(Path(user_data_dir).resolve())
|
|
121
|
+
|
|
122
|
+
# Define default profile locations for each platform
|
|
123
|
+
if system == "Darwin": # macOS
|
|
124
|
+
default_paths = [
|
|
125
|
+
str(Path.home() / "Library" / "Application Support" / "Google" / "Chrome"),
|
|
126
|
+
str(Path.home() / "Library" / "Application Support" / "Google" / "Chrome Beta"),
|
|
127
|
+
str(Path.home() / "Library" / "Application Support" / "Google" / "Chrome Canary"),
|
|
128
|
+
]
|
|
129
|
+
elif system == "Windows":
|
|
130
|
+
default_paths = [
|
|
131
|
+
str(Path.home() / "AppData" / "Local" / "Google" / "Chrome" / "User Data"),
|
|
132
|
+
str(Path.home() / "AppData" / "Local" / "Google" / "Chrome Beta" / "User Data"),
|
|
133
|
+
str(Path.home() / "AppData" / "Local" / "Google" / "Chrome SxS" / "User Data"), # Canary
|
|
134
|
+
]
|
|
135
|
+
elif system == "Linux":
|
|
136
|
+
default_paths = [
|
|
137
|
+
str(Path.home() / ".config" / "google-chrome"),
|
|
138
|
+
str(Path.home() / ".config" / "google-chrome-beta"),
|
|
139
|
+
str(Path.home() / ".config" / "google-chrome-unstable"), # Canary equivalent
|
|
140
|
+
]
|
|
141
|
+
else:
|
|
142
|
+
# Unknown platform - assume it's not default to be safe
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Resolve all default paths and check for matches
|
|
146
|
+
for default_path in default_paths:
|
|
147
|
+
try:
|
|
148
|
+
default_resolved = str(Path(default_path).resolve())
|
|
149
|
+
if user_data_dir_resolved == default_resolved:
|
|
150
|
+
return True
|
|
151
|
+
except Exception:
|
|
152
|
+
# If we can't resolve a path, skip it
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
return False
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Path utilities and management for browser configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .environment import profile_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DEFAULT_LOCK_DIR = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_lock_dir() -> str:
|
|
14
|
+
"""
|
|
15
|
+
Get the lock directory path.
|
|
16
|
+
|
|
17
|
+
Uses MCP_BROWSER_LOCK_DIR env var if set, otherwise uses:
|
|
18
|
+
<repo_root>/tmp/mcp_locks
|
|
19
|
+
|
|
20
|
+
The directory is created if it doesn't exist.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Absolute path to lock directory
|
|
24
|
+
"""
|
|
25
|
+
global _DEFAULT_LOCK_DIR
|
|
26
|
+
|
|
27
|
+
if _DEFAULT_LOCK_DIR is None:
|
|
28
|
+
# Calculate default: <repo_root>/tmp/mcp_locks
|
|
29
|
+
repo_root = Path(__file__).parent.parent.parent.parent
|
|
30
|
+
_DEFAULT_LOCK_DIR = str(repo_root / "tmp" / "mcp_locks")
|
|
31
|
+
|
|
32
|
+
lock_dir = os.getenv("MCP_BROWSER_LOCK_DIR") or _DEFAULT_LOCK_DIR
|
|
33
|
+
|
|
34
|
+
# Ensure directory exists
|
|
35
|
+
Path(lock_dir).mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
return lock_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rendezvous_path(config: dict) -> str:
|
|
41
|
+
"""Get the path to the rendezvous file for inter-process communication."""
|
|
42
|
+
return os.path.join(tempfile.gettempdir(), f"mcp_chrome_rendezvous_{profile_key(config)}.json")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def start_lock_dir(config: dict) -> str:
|
|
46
|
+
"""Get the directory path for the startup lock."""
|
|
47
|
+
return os.path.join(tempfile.gettempdir(), f"mcp_chrome_start_lock_{profile_key(config)}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def chromedriver_log_path(config: dict) -> str:
|
|
51
|
+
"""Get the path to the ChromeDriver log file."""
|
|
52
|
+
return os.path.join(tempfile.gettempdir(), f"chromedriver_shared_{profile_key(config)}_{os.getpid()}.log")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _lock_paths():
|
|
56
|
+
"""
|
|
57
|
+
Return paths for the action lock files (softlock JSON, mutex, and startup mutex).
|
|
58
|
+
These paths are based on the profile key and LOCK_DIR environment variable.
|
|
59
|
+
"""
|
|
60
|
+
from ..config.environment import get_env_config
|
|
61
|
+
|
|
62
|
+
# Get LOCK_DIR from global state
|
|
63
|
+
_DEFAULT_LOCK_DIR = str(Path(__file__).parent.parent.parent.parent / "tmp" / "mcp_locks")
|
|
64
|
+
LOCK_DIR = os.getenv("MCP_BROWSER_LOCK_DIR") or _DEFAULT_LOCK_DIR
|
|
65
|
+
Path(LOCK_DIR).mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
key = profile_key(get_env_config()) # stable across processes; independent of port
|
|
68
|
+
base = Path(LOCK_DIR)
|
|
69
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
softlock_json = base / f"{key}.softlock.json"
|
|
71
|
+
softlock_mutex = base / f"{key}.softlock.mutex"
|
|
72
|
+
startup_mutex = base / f"{key}.startup.mutex"
|
|
73
|
+
return str(softlock_json), str(softlock_mutex), str(startup_mutex)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _window_registry_path() -> str:
|
|
77
|
+
"""
|
|
78
|
+
Return the path to the window registry file for tracking window ownership.
|
|
79
|
+
"""
|
|
80
|
+
from ..config.environment import get_env_config
|
|
81
|
+
|
|
82
|
+
# Get LOCK_DIR from global state
|
|
83
|
+
_DEFAULT_LOCK_DIR = str(Path(__file__).parent.parent.parent.parent / "tmp" / "mcp_locks")
|
|
84
|
+
LOCK_DIR = os.getenv("MCP_BROWSER_LOCK_DIR") or _DEFAULT_LOCK_DIR
|
|
85
|
+
|
|
86
|
+
key = profile_key(get_env_config())
|
|
87
|
+
return os.path.join(LOCK_DIR, f"{key}.window_registry.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _same_dir(a: str, b: str) -> bool:
|
|
91
|
+
"""Compare two directory paths for equality, normalizing for platform differences."""
|
|
92
|
+
if not a or not b:
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
|
96
|
+
except Exception:
|
|
97
|
+
return a == b
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global constants and configuration defaults.
|
|
3
|
+
No dependencies - safe to import from anywhere.
|
|
4
|
+
|
|
5
|
+
This module extracts constants from helpers.py to break circular dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Lock Configuration
|
|
12
|
+
# ============================================================================
|
|
13
|
+
|
|
14
|
+
ACTION_LOCK_TTL_SECS = int(os.getenv("MCP_ACTION_LOCK_TTL", "30"))
|
|
15
|
+
"""Time-to-live for action locks in seconds."""
|
|
16
|
+
|
|
17
|
+
ACTION_LOCK_WAIT_SECS = int(os.getenv("MCP_ACTION_LOCK_WAIT", "60"))
|
|
18
|
+
"""Maximum time to wait for action lock acquisition in seconds."""
|
|
19
|
+
|
|
20
|
+
FILE_MUTEX_STALE_SECS = int(os.getenv("MCP_FILE_MUTEX_STALE_SECS", "60"))
|
|
21
|
+
"""Consider file mutex stale after this many seconds."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Window Registry Configuration
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
WINDOW_REGISTRY_STALE_THRESHOLD = int(os.getenv("MCP_WINDOW_REGISTRY_STALE_SECS", "300"))
|
|
29
|
+
"""Consider window registry entry stale after this many seconds."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# Rendering Configuration
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
MAX_SNAPSHOT_CHARS = int(os.getenv("MCP_MAX_SNAPSHOT_CHARS", "10000"))
|
|
37
|
+
"""Maximum characters in HTML snapshots."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Chrome Startup Configuration
|
|
42
|
+
# ============================================================================
|
|
43
|
+
|
|
44
|
+
START_LOCK_WAIT_SEC = 8.0
|
|
45
|
+
"""How long to wait to acquire the startup lock."""
|
|
46
|
+
|
|
47
|
+
RENDEZVOUS_TTL_SEC = 24 * 3600
|
|
48
|
+
"""How long a rendezvous file is considered valid (24 hours)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ============================================================================
|
|
52
|
+
# Feature Flags
|
|
53
|
+
# ============================================================================
|
|
54
|
+
|
|
55
|
+
ALLOW_ATTACH_ANY = os.getenv("MCP_ATTACH_ANY_PROFILE", "0") == "1"
|
|
56
|
+
"""Allow attaching to any Chrome profile, not just the configured one."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"ACTION_LOCK_TTL_SECS",
|
|
61
|
+
"ACTION_LOCK_WAIT_SECS",
|
|
62
|
+
"FILE_MUTEX_STALE_SECS",
|
|
63
|
+
"WINDOW_REGISTRY_STALE_THRESHOLD",
|
|
64
|
+
"MAX_SNAPSHOT_CHARS",
|
|
65
|
+
"START_LOCK_WAIT_SEC",
|
|
66
|
+
"RENDEZVOUS_TTL_SEC",
|
|
67
|
+
"ALLOW_ATTACH_ANY",
|
|
68
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized browser state management.
|
|
3
|
+
|
|
4
|
+
This module replaces module-level globals with a context object,
|
|
5
|
+
providing a single source of truth for browser session state.
|
|
6
|
+
|
|
7
|
+
Thread Safety:
|
|
8
|
+
The BrowserContext itself is NOT thread-safe. Access should be
|
|
9
|
+
coordinated using the locking decorators (exclusive_browser_access).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from mcp_browser_use.context import get_context
|
|
13
|
+
|
|
14
|
+
ctx = get_context()
|
|
15
|
+
if ctx.driver is None:
|
|
16
|
+
# Initialize driver
|
|
17
|
+
ctx.driver = create_driver()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Optional
|
|
21
|
+
from selenium import webdriver
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
import asyncio
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BrowserContext:
|
|
28
|
+
"""
|
|
29
|
+
Encapsulates all browser session state.
|
|
30
|
+
|
|
31
|
+
This replaces the module-level globals:
|
|
32
|
+
DRIVER, DEBUGGER_HOST, DEBUGGER_PORT, TARGET_ID,
|
|
33
|
+
WINDOW_ID, MY_TAG, LOCK_DIR, etc.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
driver: Selenium WebDriver instance
|
|
37
|
+
debugger_host: Chrome DevTools debugger hostname
|
|
38
|
+
debugger_port: Chrome DevTools debugger port
|
|
39
|
+
target_id: Chrome DevTools Protocol target ID for this window
|
|
40
|
+
window_id: Chrome window ID (from Browser.getWindowForTarget)
|
|
41
|
+
process_tag: Unique identifier for this process/session
|
|
42
|
+
config: Environment configuration dictionary
|
|
43
|
+
lock_dir: Directory for lock files
|
|
44
|
+
intra_process_lock: Asyncio lock for serializing operations within this process
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# Driver state
|
|
48
|
+
driver: Optional[webdriver.Chrome] = None
|
|
49
|
+
debugger_host: Optional[str] = None
|
|
50
|
+
debugger_port: Optional[int] = None
|
|
51
|
+
|
|
52
|
+
# Window state
|
|
53
|
+
target_id: Optional[str] = None
|
|
54
|
+
window_id: Optional[int] = None
|
|
55
|
+
|
|
56
|
+
# Process identity
|
|
57
|
+
process_tag: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
# Configuration (should be immutable after initialization)
|
|
60
|
+
config: dict = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
# Lock directory
|
|
63
|
+
lock_dir: str = ""
|
|
64
|
+
|
|
65
|
+
# Intra-process lock
|
|
66
|
+
intra_process_lock: Optional[asyncio.Lock] = None
|
|
67
|
+
|
|
68
|
+
def is_driver_initialized(self) -> bool:
|
|
69
|
+
"""Check if driver is initialized."""
|
|
70
|
+
return self.driver is not None
|
|
71
|
+
|
|
72
|
+
def is_window_ready(self) -> bool:
|
|
73
|
+
"""Check if browser window is ready."""
|
|
74
|
+
return (
|
|
75
|
+
self.driver is not None
|
|
76
|
+
and self.target_id is not None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def get_debugger_address(self) -> Optional[str]:
|
|
80
|
+
"""Get debugger address as host:port string."""
|
|
81
|
+
if self.debugger_host and self.debugger_port:
|
|
82
|
+
return f"{self.debugger_host}:{self.debugger_port}"
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def reset_window_state(self) -> None:
|
|
86
|
+
"""Reset window state (useful after window close)."""
|
|
87
|
+
self.target_id = None
|
|
88
|
+
self.window_id = None
|
|
89
|
+
|
|
90
|
+
def get_intra_process_lock(self) -> asyncio.Lock:
|
|
91
|
+
"""Get or create the intra-process asyncio lock."""
|
|
92
|
+
if self.intra_process_lock is None:
|
|
93
|
+
self.intra_process_lock = asyncio.Lock()
|
|
94
|
+
return self.intra_process_lock
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ============================================================================
|
|
98
|
+
# Global Context Management
|
|
99
|
+
# ============================================================================
|
|
100
|
+
|
|
101
|
+
_global_context: Optional[BrowserContext] = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_context() -> BrowserContext:
|
|
105
|
+
"""
|
|
106
|
+
Get or create the global browser context.
|
|
107
|
+
|
|
108
|
+
This is a singleton pattern - all calls return the same context instance.
|
|
109
|
+
Use reset_context() to clear the singleton (mainly for testing).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The global BrowserContext instance
|
|
113
|
+
"""
|
|
114
|
+
global _global_context
|
|
115
|
+
|
|
116
|
+
if _global_context is None:
|
|
117
|
+
# Lazy initialization - import here to avoid circular dependencies
|
|
118
|
+
try:
|
|
119
|
+
from .config.environment import get_env_config
|
|
120
|
+
from .config.paths import get_lock_dir
|
|
121
|
+
|
|
122
|
+
_global_context = BrowserContext(
|
|
123
|
+
config=get_env_config(),
|
|
124
|
+
lock_dir=get_lock_dir(),
|
|
125
|
+
)
|
|
126
|
+
except Exception:
|
|
127
|
+
# If config not available yet, create minimal context
|
|
128
|
+
_global_context = BrowserContext()
|
|
129
|
+
|
|
130
|
+
return _global_context
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def reset_context() -> None:
|
|
134
|
+
"""
|
|
135
|
+
Reset the global context.
|
|
136
|
+
|
|
137
|
+
⚠️ WARNING: This is primarily for testing. In production code,
|
|
138
|
+
use close_browser() instead of directly resetting context.
|
|
139
|
+
|
|
140
|
+
This will clear all state including driver, window IDs, etc.
|
|
141
|
+
"""
|
|
142
|
+
global _global_context
|
|
143
|
+
_global_context = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"BrowserContext",
|
|
148
|
+
"get_context",
|
|
149
|
+
"reset_context",
|
|
150
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# mcp_browser_use/context_pack.py
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Dict, Optional, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReturnMode:
|
|
8
|
+
HTML = "html"
|
|
9
|
+
TEXT = "text"
|
|
10
|
+
OUTLINE = "outline"
|
|
11
|
+
DOMPATHS = "dompaths"
|
|
12
|
+
MIXED = "mixed"
|
|
13
|
+
|
|
14
|
+
class CleaningLevel:
|
|
15
|
+
RAW_VISIBLE = 0
|
|
16
|
+
LIGHT = 1
|
|
17
|
+
DEFAULT = 2
|
|
18
|
+
AGGRESSIVE = 3
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class IframeInfo:
|
|
22
|
+
index: int
|
|
23
|
+
name: Optional[str]
|
|
24
|
+
id: Optional[str]
|
|
25
|
+
src: Optional[str]
|
|
26
|
+
same_origin: Optional[bool]
|
|
27
|
+
css_path: Optional[str]
|
|
28
|
+
visible: Optional[bool]
|
|
29
|
+
summary_title: Optional[str] = None
|
|
30
|
+
subtree_id: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class OutlineItem:
|
|
34
|
+
level: int
|
|
35
|
+
text: str
|
|
36
|
+
word_count: int
|
|
37
|
+
css_path: Optional[str]
|
|
38
|
+
subtree_id: Optional[str]
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CatalogInteractive:
|
|
42
|
+
role: Optional[str]
|
|
43
|
+
text_excerpt: str
|
|
44
|
+
css_path: Optional[str]
|
|
45
|
+
xpath: Optional[str]
|
|
46
|
+
nth_path: Optional[str]
|
|
47
|
+
clickable: Optional[bool]
|
|
48
|
+
enabled: Optional[bool]
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ContextPack:
|
|
52
|
+
# meta
|
|
53
|
+
window_tag: Optional[str]
|
|
54
|
+
url: Optional[str]
|
|
55
|
+
title: Optional[str]
|
|
56
|
+
page_fingerprint: Optional[str] = None
|
|
57
|
+
lock_owner: Optional[str] = None
|
|
58
|
+
lock_expires_at: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
# stats
|
|
61
|
+
cleaning_level_applied: int = CleaningLevel.DEFAULT
|
|
62
|
+
approx_tokens: int = 0
|
|
63
|
+
pruned_counts: Dict[str, int] = field(default_factory=dict)
|
|
64
|
+
tokens_budget: Optional[int] = None
|
|
65
|
+
nodes_kept: Optional[int] = None
|
|
66
|
+
nodes_pruned: Optional[int] = None
|
|
67
|
+
hard_capped: bool = False
|
|
68
|
+
|
|
69
|
+
# presence flags
|
|
70
|
+
snapshot_mode: str = ReturnMode.OUTLINE
|
|
71
|
+
outline_present: bool = False
|
|
72
|
+
diff_present: bool = False
|
|
73
|
+
iframe_index_present: bool = False
|
|
74
|
+
|
|
75
|
+
# payloads
|
|
76
|
+
outline: List[OutlineItem] = field(default_factory=list)
|
|
77
|
+
html: Optional[str] = None
|
|
78
|
+
text: Optional[str] = None
|
|
79
|
+
dompaths: Optional[List[Dict[str, Any]]] = None
|
|
80
|
+
mixed: Optional[Dict[str, Any]] = None
|
|
81
|
+
catalogs: Optional[Dict[str, Any]] = None
|
|
82
|
+
forms: Optional[Dict[str, Any]] = None
|
|
83
|
+
iframe_index: Optional[List[IframeInfo]] = None
|
|
84
|
+
|
|
85
|
+
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# mcp_browser_use/decorators/__init__.py
|
|
2
|
+
#
|
|
3
|
+
# Re-exports decorators from their respective modules.
|
|
4
|
+
|
|
5
|
+
from .ensure import ensure_driver_ready
|
|
6
|
+
from .locking import exclusive_browser_access
|
|
7
|
+
from .envelope import tool_envelope
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ensure_driver_ready",
|
|
11
|
+
"exclusive_browser_access",
|
|
12
|
+
"tool_envelope",
|
|
13
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# mcp_browser_use/decorators/ensure.py
|
|
2
|
+
import json
|
|
3
|
+
import inspect
|
|
4
|
+
import functools
|
|
5
|
+
|
|
6
|
+
def ensure_driver_ready(_func=None, *, include_snapshot=False, include_diagnostics=False):
|
|
7
|
+
def decorator(fn):
|
|
8
|
+
if inspect.iscoroutinefunction(fn):
|
|
9
|
+
@functools.wraps(fn)
|
|
10
|
+
async def wrapper(*args, **kwargs):
|
|
11
|
+
import mcp_browser_use.helpers as helpers # module import, not from-import
|
|
12
|
+
from mcp_browser_use.config.environment import get_env_config
|
|
13
|
+
from mcp_browser_use.utils.diagnostics import collect_diagnostics
|
|
14
|
+
|
|
15
|
+
# Check if driver is already initialized, but don't auto-initialize
|
|
16
|
+
if helpers.get_context().driver is None:
|
|
17
|
+
payload = {
|
|
18
|
+
"ok": False,
|
|
19
|
+
"error": "browser_not_started",
|
|
20
|
+
"message": "Browser session not started. Please call 'start_browser' first before using browser actions."
|
|
21
|
+
}
|
|
22
|
+
if include_snapshot:
|
|
23
|
+
payload["snapshot"] = {"url": None, "title": None, "html": "", "truncated": False}
|
|
24
|
+
if include_diagnostics:
|
|
25
|
+
try:
|
|
26
|
+
payload["diagnostics"] = collect_diagnostics(None, None, get_env_config())
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return json.dumps(payload)
|
|
30
|
+
|
|
31
|
+
# Ensure we have a valid window for this driver
|
|
32
|
+
try:
|
|
33
|
+
helpers._ensure_singleton_window(helpers.get_context().driver)
|
|
34
|
+
except Exception:
|
|
35
|
+
payload = {
|
|
36
|
+
"ok": False,
|
|
37
|
+
"error": "browser_window_lost",
|
|
38
|
+
"message": "Browser window was lost. Please call 'start_browser' to create a new session."
|
|
39
|
+
}
|
|
40
|
+
if include_snapshot:
|
|
41
|
+
payload["snapshot"] = {"url": None, "title": None, "html": "", "truncated": False}
|
|
42
|
+
return json.dumps(payload)
|
|
43
|
+
|
|
44
|
+
return await fn(*args, **kwargs)
|
|
45
|
+
return wrapper
|
|
46
|
+
else:
|
|
47
|
+
@functools.wraps(fn)
|
|
48
|
+
def wrapper(*args, **kwargs):
|
|
49
|
+
import mcp_browser_use.helpers as helpers # module import, not from-import
|
|
50
|
+
from mcp_browser_use.config.environment import get_env_config
|
|
51
|
+
from mcp_browser_use.utils.diagnostics import collect_diagnostics
|
|
52
|
+
|
|
53
|
+
# Check if driver is already initialized, but don't auto-initialize
|
|
54
|
+
if helpers.get_context().driver is None:
|
|
55
|
+
payload = {
|
|
56
|
+
"ok": False,
|
|
57
|
+
"error": "browser_not_started",
|
|
58
|
+
"message": "Browser session not started. Please call 'start_browser' first before using browser actions."
|
|
59
|
+
}
|
|
60
|
+
if include_snapshot:
|
|
61
|
+
payload["snapshot"] = {"url": None, "title": None, "html": "", "truncated": False}
|
|
62
|
+
if include_diagnostics:
|
|
63
|
+
try:
|
|
64
|
+
payload["diagnostics"] = collect_diagnostics(None, None, get_env_config())
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return json.dumps(payload)
|
|
68
|
+
|
|
69
|
+
# Ensure we have a valid window for this driver
|
|
70
|
+
try:
|
|
71
|
+
helpers._ensure_singleton_window(helpers.get_context().driver)
|
|
72
|
+
except Exception:
|
|
73
|
+
payload = {
|
|
74
|
+
"ok": False,
|
|
75
|
+
"error": "browser_window_lost",
|
|
76
|
+
"message": "Browser window was lost. Please call 'start_browser' to create a new session."
|
|
77
|
+
}
|
|
78
|
+
if include_snapshot:
|
|
79
|
+
payload["snapshot"] = {"url": None, "title": None, "html": "", "truncated": False}
|
|
80
|
+
return json.dumps(payload)
|
|
81
|
+
|
|
82
|
+
return fn(*args, **kwargs)
|
|
83
|
+
return wrapper
|
|
84
|
+
return decorator if _func is None else decorator(_func)
|