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.
Files changed (50) hide show
  1. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/METADATA +26 -0
  2. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/RECORD +50 -0
  3. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. iflow_mcp_janspoerer_mcp_browser_use-0.1.0.dist-info/top_level.txt +1 -0
  7. mcp_browser_use/__init__.py +2 -0
  8. mcp_browser_use/__main__.py +1347 -0
  9. mcp_browser_use/actions/__init__.py +1 -0
  10. mcp_browser_use/actions/elements.py +173 -0
  11. mcp_browser_use/actions/extraction.py +864 -0
  12. mcp_browser_use/actions/keyboard.py +43 -0
  13. mcp_browser_use/actions/navigation.py +73 -0
  14. mcp_browser_use/actions/screenshots.py +85 -0
  15. mcp_browser_use/browser/__init__.py +1 -0
  16. mcp_browser_use/browser/chrome.py +150 -0
  17. mcp_browser_use/browser/chrome_executable.py +204 -0
  18. mcp_browser_use/browser/chrome_launcher.py +330 -0
  19. mcp_browser_use/browser/chrome_process.py +104 -0
  20. mcp_browser_use/browser/devtools.py +230 -0
  21. mcp_browser_use/browser/driver.py +322 -0
  22. mcp_browser_use/browser/process.py +133 -0
  23. mcp_browser_use/cleaners.py +530 -0
  24. mcp_browser_use/config/__init__.py +30 -0
  25. mcp_browser_use/config/environment.py +155 -0
  26. mcp_browser_use/config/paths.py +97 -0
  27. mcp_browser_use/constants.py +68 -0
  28. mcp_browser_use/context.py +150 -0
  29. mcp_browser_use/context_pack.py +85 -0
  30. mcp_browser_use/decorators/__init__.py +13 -0
  31. mcp_browser_use/decorators/ensure.py +84 -0
  32. mcp_browser_use/decorators/envelope.py +83 -0
  33. mcp_browser_use/decorators/locking.py +172 -0
  34. mcp_browser_use/helpers.py +173 -0
  35. mcp_browser_use/helpers_context.py +261 -0
  36. mcp_browser_use/locking/__init__.py +1 -0
  37. mcp_browser_use/locking/action_lock.py +190 -0
  38. mcp_browser_use/locking/file_mutex.py +139 -0
  39. mcp_browser_use/locking/window_registry.py +178 -0
  40. mcp_browser_use/tools/__init__.py +59 -0
  41. mcp_browser_use/tools/browser_management.py +260 -0
  42. mcp_browser_use/tools/debugging.py +195 -0
  43. mcp_browser_use/tools/extraction.py +58 -0
  44. mcp_browser_use/tools/interaction.py +323 -0
  45. mcp_browser_use/tools/navigation.py +84 -0
  46. mcp_browser_use/tools/screenshots.py +116 -0
  47. mcp_browser_use/utils/__init__.py +1 -0
  48. mcp_browser_use/utils/diagnostics.py +85 -0
  49. mcp_browser_use/utils/html_utils.py +118 -0
  50. mcp_browser_use/utils/retry.py +57 -0
@@ -0,0 +1,190 @@
1
+ """Action lock management for coordinating browser actions across processes."""
2
+
3
+ import os
4
+ import json
5
+ import time
6
+ import asyncio
7
+ from typing import Dict, Any, Optional
8
+
9
+ # Global intra-process lock
10
+ MCP_INTRA_PROCESS_LOCK: Optional[asyncio.Lock] = None
11
+
12
+
13
+ def get_intra_process_lock() -> asyncio.Lock:
14
+ """Get or create the intra-process asyncio lock."""
15
+ global MCP_INTRA_PROCESS_LOCK
16
+ if MCP_INTRA_PROCESS_LOCK is None:
17
+ MCP_INTRA_PROCESS_LOCK = asyncio.Lock()
18
+ return MCP_INTRA_PROCESS_LOCK
19
+
20
+
21
+ def _read_softlock(path: str) -> Dict[str, Any]:
22
+ """Read softlock JSON file."""
23
+ try:
24
+ with open(path, "r", encoding="utf-8") as f:
25
+ return json.load(f) or {}
26
+ except FileNotFoundError:
27
+ return {}
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def _write_softlock(path: str, state: Dict[str, Any]):
33
+ """Write softlock JSON file atomically."""
34
+ tmp = f"{path}.tmp"
35
+ with open(tmp, "w", encoding="utf-8") as f:
36
+ json.dump(state, f)
37
+ os.replace(tmp, path)
38
+
39
+
40
+ def _acquire_softlock(owner: str, ttl: int, wait: bool = True, wait_timeout: float = None) -> Dict[str, Any]:
41
+ """
42
+ Acquire a softlock for the given owner.
43
+
44
+ Args:
45
+ owner: Identifier of the lock owner
46
+ ttl: Time to live in seconds
47
+ wait: Whether to wait for the lock
48
+ wait_timeout: Maximum time to wait (defaults to ACTION_LOCK_WAIT_SECS)
49
+
50
+ Returns:
51
+ Dictionary with lock acquisition status
52
+ """
53
+ from .file_mutex import _lock_paths, _file_mutex, _now
54
+ from ..constants import FILE_MUTEX_STALE_SECS, ACTION_LOCK_WAIT_SECS
55
+
56
+ if wait_timeout is None:
57
+ wait_timeout = ACTION_LOCK_WAIT_SECS
58
+
59
+ softlock_json, softlock_mutex, _ = _lock_paths()
60
+ deadline = _now() + max(0.0, wait_timeout)
61
+
62
+ while True:
63
+ try:
64
+ with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=min(5.0, max(0.1, deadline - _now()))):
65
+ state = _read_softlock(softlock_json)
66
+ cur_owner = state.get("owner")
67
+ expires_at = float(state.get("expires_at", 0.0))
68
+
69
+ if not cur_owner or expires_at <= _now() or cur_owner == owner:
70
+ new_exp = _now() + ttl
71
+ _write_softlock(softlock_json, {"owner": owner, "expires_at": new_exp})
72
+ return {"acquired": True, "owner": owner, "expires_at": new_exp}
73
+
74
+ result = {
75
+ "acquired": False,
76
+ "owner": cur_owner,
77
+ "expires_at": float(expires_at),
78
+ "message": "busy",
79
+ }
80
+ except TimeoutError:
81
+ if not wait or _now() >= deadline:
82
+ # Best-effort read without mutex for context
83
+ state = _read_softlock(softlock_json)
84
+ return {
85
+ "acquired": False,
86
+ "owner": state.get("owner"),
87
+ "expires_at": float(state.get("expires_at", 0.0)) if state.get("expires_at") else None,
88
+ "message": "mutex_timeout",
89
+ }
90
+
91
+ if not wait or _now() >= deadline:
92
+ return result
93
+ time.sleep(0.05)
94
+
95
+
96
+ def _release_action_lock(owner: str) -> bool:
97
+ """
98
+ Release the action lock if owned by the given owner.
99
+
100
+ Args:
101
+ owner: Identifier of the lock owner
102
+
103
+ Returns:
104
+ True if lock was released, False otherwise
105
+ """
106
+ from .file_mutex import _lock_paths, _file_mutex
107
+ from ..constants import FILE_MUTEX_STALE_SECS
108
+
109
+ softlock_json, softlock_mutex, _ = _lock_paths()
110
+ with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=5.0):
111
+ state = _read_softlock(softlock_json)
112
+ if state.get("owner") == owner:
113
+ _write_softlock(softlock_json, {})
114
+ return True
115
+ return False
116
+
117
+
118
+ def _renew_action_lock(owner: str, ttl: int) -> bool:
119
+ """
120
+ Extend the action lock if owned by `owner`, or if stale. No-op if owned by someone else and not stale.
121
+ Also updates the window registry heartbeat as a piggyback optimization.
122
+
123
+ Args:
124
+ owner: Identifier of the lock owner
125
+ ttl: Time to live in seconds
126
+
127
+ Returns:
128
+ True if we wrote a new expiry, False otherwise
129
+ """
130
+ from .file_mutex import _lock_paths, _file_mutex, _now
131
+ from .window_registry import _update_window_heartbeat
132
+ from ..constants import FILE_MUTEX_STALE_SECS
133
+
134
+ softlock_json, softlock_mutex, _ = _lock_paths()
135
+ try:
136
+ with _file_mutex(softlock_mutex, stale_secs=FILE_MUTEX_STALE_SECS, wait_timeout=1.0):
137
+ state = _read_softlock(softlock_json)
138
+ cur_owner = state.get("owner")
139
+ expires_at = float(state.get("expires_at", 0.0) or 0.0)
140
+
141
+ if cur_owner == owner or expires_at <= _now():
142
+ new_exp = _now() + int(ttl)
143
+ _write_softlock(softlock_json, {"owner": owner, "expires_at": new_exp})
144
+
145
+ # Piggyback: update window heartbeat while we're renewing the lock
146
+ try:
147
+ _update_window_heartbeat(owner)
148
+ except Exception:
149
+ pass # Non-critical
150
+
151
+ return True
152
+ return False
153
+ except Exception:
154
+ return False
155
+
156
+
157
+ def _acquire_action_lock_or_error(owner: str) -> Optional[str]:
158
+ """
159
+ Acquire action lock or return error message.
160
+
161
+ Args:
162
+ owner: Identifier of the lock owner
163
+
164
+ Returns:
165
+ None if lock acquired, error JSON string otherwise
166
+ """
167
+ from ..constants import ACTION_LOCK_TTL_SECS, ACTION_LOCK_WAIT_SECS
168
+
169
+ res = _acquire_softlock(owner=owner, ttl=ACTION_LOCK_TTL_SECS, wait=True, wait_timeout=ACTION_LOCK_WAIT_SECS)
170
+ if res.get("acquired"):
171
+ return None
172
+
173
+ return json.dumps({
174
+ "ok": False,
175
+ "error": "locked",
176
+ "owner": res.get("owner"),
177
+ "expires_at": res.get("expires_at"),
178
+ "lock_ttl_seconds": ACTION_LOCK_TTL_SECS,
179
+ })
180
+
181
+
182
+ __all__ = [
183
+ 'get_intra_process_lock',
184
+ '_read_softlock',
185
+ '_write_softlock',
186
+ '_acquire_softlock',
187
+ '_release_action_lock',
188
+ '_renew_action_lock',
189
+ '_acquire_action_lock_or_error',
190
+ ]
@@ -0,0 +1,139 @@
1
+ """File-based mutex and startup lock implementation."""
2
+
3
+ import os
4
+ import time
5
+ import shutil
6
+ import tempfile
7
+ import contextlib
8
+ import psutil
9
+ from pathlib import Path
10
+
11
+
12
+ def _now() -> float:
13
+ """Return current time as float timestamp."""
14
+ return time.time()
15
+
16
+
17
+ def _lock_paths():
18
+ """
19
+ Get paths for lock files based on profile key.
20
+
21
+ Returns:
22
+ Tuple of (softlock_json, softlock_mutex, startup_mutex) paths
23
+ """
24
+ # Import here to avoid circular dependency
25
+ from ..config.environment import profile_key, get_env_config
26
+ from ..config.paths import get_lock_dir
27
+
28
+ key = profile_key(get_env_config()) # stable across processes; independent of port
29
+ base = Path(get_lock_dir())
30
+ base.mkdir(parents=True, exist_ok=True)
31
+ softlock_json = base / f"{key}.softlock.json"
32
+ softlock_mutex = base / f"{key}.softlock.mutex"
33
+ startup_mutex = base / f"{key}.startup.mutex"
34
+ return str(softlock_json), str(softlock_mutex), str(startup_mutex)
35
+
36
+
37
+ @contextlib.contextmanager
38
+ def _file_mutex(path: str, stale_secs: int, wait_timeout: float):
39
+ """
40
+ Simple cross-process mutex via an exclusive file create.
41
+ Removes stale mutex if older than stale_secs.
42
+ """
43
+ start = _now()
44
+ p = Path(path)
45
+ while True:
46
+ try:
47
+ fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_RDWR)
48
+ break
49
+ except FileExistsError:
50
+ try:
51
+ st = p.stat()
52
+ if _now() - st.st_mtime > stale_secs:
53
+ p.unlink(missing_ok=True)
54
+ continue
55
+ except FileNotFoundError:
56
+ continue
57
+ if _now() - start > wait_timeout:
58
+ raise TimeoutError(f"Timed out waiting for mutex {p}")
59
+ time.sleep(0.05)
60
+ try:
61
+ yield
62
+ finally:
63
+ try:
64
+ os.close(fd)
65
+ except Exception:
66
+ pass
67
+ try:
68
+ p.unlink(missing_ok=True)
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ def start_lock_dir(config: dict) -> str:
74
+ """Get path to startup lock directory for the given profile."""
75
+ from ..config.environment import profile_key
76
+ return os.path.join(tempfile.gettempdir(), f"mcp_chrome_start_lock_{profile_key(config)}")
77
+
78
+
79
+ def acquire_start_lock(config: dict, timeout_sec: float = None) -> bool:
80
+ """
81
+ Acquire startup lock to ensure only one process starts Chrome.
82
+
83
+ Args:
84
+ config: Configuration dictionary
85
+ timeout_sec: Timeout in seconds (defaults to START_LOCK_WAIT_SEC from helpers)
86
+
87
+ Returns:
88
+ True if lock acquired, False on timeout
89
+ """
90
+ if timeout_sec is None:
91
+ from ..constants import START_LOCK_WAIT_SEC
92
+ timeout_sec = START_LOCK_WAIT_SEC
93
+
94
+ lock_dir = start_lock_dir(config)
95
+ deadline = time.time() + timeout_sec
96
+ while time.time() < deadline:
97
+ try:
98
+ os.mkdir(lock_dir)
99
+ with open(os.path.join(lock_dir, "pid"), "w") as f:
100
+ f.write(str(os.getpid()))
101
+ return True
102
+ except FileExistsError:
103
+ # If owner died, break it
104
+ pid_file = os.path.join(lock_dir, "pid")
105
+ try:
106
+ if os.path.exists(pid_file):
107
+ with open(pid_file, "r") as f:
108
+ pid_txt = f.read().strip()
109
+ pid = int(pid_txt) if pid_txt.isdigit() else None
110
+ else:
111
+ pid = None
112
+ except Exception:
113
+ pid = None
114
+ if pid and not psutil.pid_exists(pid):
115
+ try:
116
+ shutil.rmtree(lock_dir, ignore_errors=True)
117
+ continue
118
+ except Exception:
119
+ pass
120
+ time.sleep(0.05)
121
+ return False
122
+
123
+
124
+ def release_start_lock(config: dict) -> None:
125
+ """Release startup lock."""
126
+ try:
127
+ shutil.rmtree(start_lock_dir(config), ignore_errors=True)
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ __all__ = [
133
+ '_now',
134
+ '_lock_paths',
135
+ '_file_mutex',
136
+ 'start_lock_dir',
137
+ 'acquire_start_lock',
138
+ 'release_start_lock',
139
+ ]
@@ -0,0 +1,178 @@
1
+ """Window registry for tracking browser window ownership across processes."""
2
+
3
+ import os
4
+ import json
5
+ import time
6
+ import psutil
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional
9
+
10
+ import logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _window_registry_path() -> str:
15
+ """Get path to window registry file for this profile."""
16
+ from ..config.environment import profile_key, get_env_config
17
+ from ..config.paths import get_lock_dir
18
+
19
+ key = profile_key(get_env_config())
20
+ return str(Path(get_lock_dir()) / f"{key}.window_registry.json")
21
+
22
+
23
+ def _read_window_registry() -> Dict[str, Any]:
24
+ """Read the window registry. Returns empty dict if not found or invalid."""
25
+ path = _window_registry_path()
26
+ try:
27
+ with open(path, "r", encoding="utf-8") as f:
28
+ return json.load(f) or {}
29
+ except FileNotFoundError:
30
+ return {}
31
+ except Exception:
32
+ return {}
33
+
34
+
35
+ def _write_window_registry(registry: Dict[str, Any]):
36
+ """Write window registry atomically using temp file + rename."""
37
+ path = _window_registry_path()
38
+ tmp = f"{path}.tmp"
39
+ try:
40
+ with open(tmp, "w", encoding="utf-8") as f:
41
+ json.dump(registry, f, indent=2)
42
+ os.replace(tmp, path)
43
+ except Exception:
44
+ pass # Non-critical if write fails
45
+
46
+
47
+ def _register_window(agent_id: str, target_id: str, window_id: Optional[int]):
48
+ """Register a window as owned by this agent."""
49
+ registry = _read_window_registry()
50
+ registry[agent_id] = {
51
+ "target_id": target_id,
52
+ "window_id": window_id,
53
+ "pid": os.getpid(),
54
+ "last_heartbeat": time.time(),
55
+ "created_at": time.time(),
56
+ }
57
+ _write_window_registry(registry)
58
+
59
+
60
+ def _update_window_heartbeat(agent_id: str):
61
+ """Update the heartbeat timestamp for this agent's window."""
62
+ registry = _read_window_registry()
63
+ if agent_id in registry:
64
+ registry[agent_id]["last_heartbeat"] = time.time()
65
+ _write_window_registry(registry)
66
+
67
+
68
+ def _unregister_window(agent_id: str):
69
+ """Remove this agent's window from the registry."""
70
+ registry = _read_window_registry()
71
+ if agent_id in registry:
72
+ del registry[agent_id]
73
+ _write_window_registry(registry)
74
+
75
+
76
+ def cleanup_orphaned_windows(driver, *, close_on_stale: bool = False):
77
+ """
78
+ Close windows owned by dead processes. Optionally close very stale windows if explicitly enabled.
79
+
80
+ - Default behavior: only close when the owning PID no longer exists.
81
+ - Stale heartbeats are logged but not closed by default to avoid killing idle sessions.
82
+
83
+ Args:
84
+ driver: Selenium WebDriver instance
85
+ close_on_stale: If True, also close windows with stale heartbeats (default False)
86
+ """
87
+ from ..constants import WINDOW_REGISTRY_STALE_THRESHOLD
88
+
89
+ # If you have a registry/file lock, acquire it here
90
+ # with _registry_lock():
91
+ registry = _read_window_registry()
92
+ now = time.time()
93
+
94
+ to_remove: list[str] = []
95
+ changed = False
96
+
97
+ # Optional: detect already-missing targets to avoid noisy close attempts
98
+ try:
99
+ targets_resp = driver.execute_cdp_cmd("Target.getTargets", {})
100
+ known_targets = {t.get("targetId") for t in targets_resp.get("targetInfos", [])}
101
+ except Exception:
102
+ known_targets = None # fall back to best-effort without pre-check
103
+
104
+ for agent_id, info in list(registry.items()):
105
+ pid = info.get("pid")
106
+ last_hb = info.get("last_heartbeat")
107
+ target_id = info.get("target_id")
108
+
109
+ # Robustness: skip weird records
110
+ if target_id is None:
111
+ logger.info(f"Removing registry entry with no target_id: agent={agent_id}, pid={pid}")
112
+ to_remove.append(agent_id)
113
+ changed = True
114
+ continue
115
+
116
+ # Compute states safely
117
+ try:
118
+ is_dead = bool(pid) and not psutil.pid_exists(int(pid))
119
+ except Exception:
120
+ # If pid cannot be parsed, treat as unknown (do not close)
121
+ is_dead = False
122
+
123
+ is_stale = False
124
+ if isinstance(last_hb, (int, float)):
125
+ try:
126
+ is_stale = (now - float(last_hb)) > WINDOW_REGISTRY_STALE_THRESHOLD
127
+ except Exception:
128
+ is_stale = False
129
+
130
+ # If target is already gone, just drop the registry entry
131
+ if known_targets is not None and target_id not in known_targets:
132
+ logger.info(f"Target already gone; pruning registry entry: agent={agent_id}, target={target_id}")
133
+ to_remove.append(agent_id)
134
+ changed = True
135
+ continue
136
+
137
+ # Decide whether to close
138
+ should_close = is_dead or (close_on_stale and is_stale)
139
+ if not should_close:
140
+ if is_stale:
141
+ logger.debug(f"Stale heartbeat but not closing (agent={agent_id}, pid={pid})")
142
+ continue
143
+
144
+ # Try to close the target
145
+ try:
146
+ res = driver.execute_cdp_cmd("Target.closeTarget", {"targetId": target_id})
147
+ success = (res.get("success", True) if isinstance(res, dict) else True)
148
+ logger.info(
149
+ f"Closed orphaned window: agent={agent_id}, target={target_id}, "
150
+ f"dead={is_dead}, stale={is_stale}, success={success}"
151
+ )
152
+ except Exception as e:
153
+ # Even if we fail to close a window of a dead process, remove the entry to avoid leaks
154
+ logger.debug(f"Could not close target {target_id} for agent {agent_id}: {e}")
155
+
156
+ to_remove.append(agent_id)
157
+ changed = True
158
+
159
+ # Clean up registry
160
+ if to_remove:
161
+ for agent_id in to_remove:
162
+ registry.pop(agent_id, None)
163
+ _write_window_registry(registry)
164
+ logger.info(f"Cleaned up {len(to_remove)} window registry entry(ies)")
165
+ elif changed:
166
+ # In case we changed something else
167
+ _write_window_registry(registry)
168
+
169
+
170
+ __all__ = [
171
+ '_window_registry_path',
172
+ '_read_window_registry',
173
+ '_write_window_registry',
174
+ '_register_window',
175
+ '_update_window_heartbeat',
176
+ '_unregister_window',
177
+ 'cleanup_orphaned_windows',
178
+ ]
@@ -0,0 +1,59 @@
1
+ # mcp_browser_use/tools/__init__.py
2
+ """
3
+ MCP tool implementations - async wrappers that return JSON responses.
4
+
5
+ This package contains high-level tool implementations that:
6
+ - Wrap lower-level helpers/actions
7
+ - Return JSON-serialized responses
8
+ - Include error handling and diagnostics
9
+ - Provide page snapshots
10
+ """
11
+
12
+ from .browser_management import (
13
+ start_browser,
14
+ unlock_browser,
15
+ close_browser,
16
+ force_close_all_chrome,
17
+ )
18
+
19
+ from .navigation import (
20
+ navigate_to_url,
21
+ scroll,
22
+ )
23
+
24
+ from .interaction import (
25
+ fill_text,
26
+ click_element,
27
+ send_keys,
28
+ wait_for_element,
29
+ )
30
+
31
+ from .debugging import (
32
+ get_debug_diagnostics_info,
33
+ debug_element,
34
+ )
35
+
36
+ from .screenshots import (
37
+ take_screenshot,
38
+ )
39
+
40
+ __all__ = [
41
+ # Browser management
42
+ 'start_browser',
43
+ 'unlock_browser',
44
+ 'close_browser',
45
+ 'force_close_all_chrome',
46
+ # Navigation
47
+ 'navigate_to_url',
48
+ 'scroll',
49
+ # Interaction
50
+ 'fill_text',
51
+ 'click_element',
52
+ 'send_keys',
53
+ 'wait_for_element',
54
+ # Debugging
55
+ 'get_debug_diagnostics_info',
56
+ 'debug_element',
57
+ # Screenshots
58
+ 'take_screenshot',
59
+ ]