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,83 @@
|
|
|
1
|
+
# mcp_browser_use/decorators/envelope.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import datetime
|
|
8
|
+
import functools
|
|
9
|
+
import traceback
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"tool_envelope",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tool_envelope(func: Callable):
|
|
19
|
+
"""
|
|
20
|
+
Minimal decorator for MCP tool functions:
|
|
21
|
+
- Works with both async and sync callables.
|
|
22
|
+
- On success: ensures the return value is a string (json.dumps for non-strings).
|
|
23
|
+
- On error: returns a uniform JSON string with a summary and optional traceback.
|
|
24
|
+
Environment:
|
|
25
|
+
- Set MBU_TOOL_ERRORS_TRACEBACK=0 to suppress traceback in error payloads.
|
|
26
|
+
"""
|
|
27
|
+
include_tb = os.getenv("MBU_TOOL_ERRORS_TRACEBACK", "1") not in ("0", "false", "False")
|
|
28
|
+
|
|
29
|
+
def _normalize(value: Any) -> str:
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, bytes):
|
|
35
|
+
try:
|
|
36
|
+
return value.decode("utf-8")
|
|
37
|
+
except Exception:
|
|
38
|
+
return value.decode("utf-8", "replace")
|
|
39
|
+
try:
|
|
40
|
+
return json.dumps(value, ensure_ascii=False, default=lambda o: getattr(o, "__dict__", repr(o)))
|
|
41
|
+
except Exception:
|
|
42
|
+
# Fallback to a best-effort string
|
|
43
|
+
try:
|
|
44
|
+
return str(value)
|
|
45
|
+
except Exception:
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
def _error_payload(err: Exception) -> str:
|
|
49
|
+
tb = traceback.format_exc() if include_tb else None
|
|
50
|
+
payload = {
|
|
51
|
+
"ok": False,
|
|
52
|
+
"summary": f"{err.__class__.__name__}: {err}",
|
|
53
|
+
"error": {
|
|
54
|
+
"type": err.__class__.__name__,
|
|
55
|
+
"message": str(err),
|
|
56
|
+
},
|
|
57
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
58
|
+
}
|
|
59
|
+
if tb:
|
|
60
|
+
payload["error"]["traceback"] = tb
|
|
61
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
62
|
+
|
|
63
|
+
if inspect.iscoroutinefunction(func):
|
|
64
|
+
@functools.wraps(func)
|
|
65
|
+
async def wrapper(*args, **kwargs):
|
|
66
|
+
try:
|
|
67
|
+
result = await func(*args, **kwargs)
|
|
68
|
+
except asyncio.CancelledError:
|
|
69
|
+
# Preserve cooperative cancellation semantics
|
|
70
|
+
raise
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return _error_payload(e)
|
|
73
|
+
return _normalize(result)
|
|
74
|
+
return wrapper
|
|
75
|
+
else:
|
|
76
|
+
@functools.wraps(func)
|
|
77
|
+
def wrapper(*args, **kwargs):
|
|
78
|
+
try:
|
|
79
|
+
result = func(*args, **kwargs)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return _error_payload(e)
|
|
82
|
+
return _normalize(result)
|
|
83
|
+
return wrapper
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# mcp_browser_use/decorators/locking.py
|
|
2
|
+
#
|
|
3
|
+
# Known Limitation: Iframe Context
|
|
4
|
+
# Multi-step iframe interactions require specifying iframe_selector for each action.
|
|
5
|
+
# This is intentional design to prevent context state bugs.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
If multiple agents are working at the same time, there will be some natural
|
|
10
|
+
concurrency because the agents anyway need to produce tokens (think) before
|
|
11
|
+
calling the next action. This makes issues arising from sequential locking a
|
|
12
|
+
bit less pronounced.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import json
|
|
19
|
+
import asyncio
|
|
20
|
+
import inspect
|
|
21
|
+
import warnings
|
|
22
|
+
import functools
|
|
23
|
+
import contextlib
|
|
24
|
+
import threading
|
|
25
|
+
import time as _time
|
|
26
|
+
from typing import Callable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"exclusive_browser_access",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_config_or_error():
|
|
35
|
+
"""
|
|
36
|
+
Validate browser configuration early to provide clear error messages.
|
|
37
|
+
|
|
38
|
+
Returns None if valid, or JSON error string if invalid.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
from mcp_browser_use.config.environment import get_env_config
|
|
42
|
+
get_env_config() # Just validate - don't store since config never changes
|
|
43
|
+
return None # Valid config
|
|
44
|
+
except Exception as e:
|
|
45
|
+
error_payload = {
|
|
46
|
+
"ok": False,
|
|
47
|
+
"error": "invalid_configuration",
|
|
48
|
+
"message": f"Browser configuration error: {str(e)}. Please check your environment variables.",
|
|
49
|
+
"details": {
|
|
50
|
+
"required": ["CHROME_PROFILE_USER_DATA_DIR or BETA_PROFILE_USER_DATA_DIR or CANARY_PROFILE_USER_DATA_DIR"],
|
|
51
|
+
"optional": ["CHROME_EXECUTABLE_PATH", "CHROME_REMOTE_DEBUG_PORT"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return json.dumps(error_payload)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def exclusive_browser_access(_func=None):
|
|
58
|
+
"""
|
|
59
|
+
Acquire the action lock, keep it alive with a heartbeat while the function runs,
|
|
60
|
+
and renew once on exit. Also serializes calls within this process.
|
|
61
|
+
Use on tools that mutate or depend on exclusive browser access.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def decorator(func):
|
|
65
|
+
if inspect.iscoroutinefunction(func):
|
|
66
|
+
@functools.wraps(func)
|
|
67
|
+
async def wrapper(*args, **kwargs):
|
|
68
|
+
# Early config validation to prevent race condition
|
|
69
|
+
config_error = _validate_config_or_error()
|
|
70
|
+
if config_error:
|
|
71
|
+
return config_error
|
|
72
|
+
|
|
73
|
+
# Import lazily to avoid cycles at module import time
|
|
74
|
+
from mcp_browser_use.constants import ACTION_LOCK_TTL_SECS
|
|
75
|
+
from mcp_browser_use.locking.action_lock import (
|
|
76
|
+
get_intra_process_lock,
|
|
77
|
+
_acquire_action_lock_or_error,
|
|
78
|
+
_renew_action_lock,
|
|
79
|
+
)
|
|
80
|
+
from mcp_browser_use.browser.process import ensure_process_tag
|
|
81
|
+
|
|
82
|
+
# Ensure process tag exists
|
|
83
|
+
owner = ensure_process_tag()
|
|
84
|
+
|
|
85
|
+
# In-process serialization across tools
|
|
86
|
+
lock = get_intra_process_lock()
|
|
87
|
+
async with lock:
|
|
88
|
+
# Acquire cross-process action lock (waits up to ACTION_LOCK_WAIT_SECS)
|
|
89
|
+
err = _acquire_action_lock_or_error(owner)
|
|
90
|
+
if err:
|
|
91
|
+
# err is already a JSON string; let your tool_envelope pass it through
|
|
92
|
+
return err
|
|
93
|
+
|
|
94
|
+
stop = asyncio.Event()
|
|
95
|
+
|
|
96
|
+
async def _beater():
|
|
97
|
+
try:
|
|
98
|
+
while not stop.is_set():
|
|
99
|
+
try:
|
|
100
|
+
await asyncio.wait_for(stop.wait(), timeout=1.0)
|
|
101
|
+
break
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
pass
|
|
104
|
+
try:
|
|
105
|
+
_renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
except asyncio.CancelledError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
task = asyncio.create_task(_beater())
|
|
112
|
+
try:
|
|
113
|
+
return await func(*args, **kwargs)
|
|
114
|
+
finally:
|
|
115
|
+
stop.set()
|
|
116
|
+
task.cancel()
|
|
117
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
118
|
+
await task
|
|
119
|
+
with contextlib.suppress(Exception):
|
|
120
|
+
_renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
|
|
121
|
+
return wrapper
|
|
122
|
+
|
|
123
|
+
# Optional sync path (rare in your code); no asyncio.Lock here.
|
|
124
|
+
@functools.wraps(func)
|
|
125
|
+
def wrapper(*args, **kwargs):
|
|
126
|
+
# Early config validation to prevent race condition
|
|
127
|
+
config_error = _validate_config_or_error()
|
|
128
|
+
if config_error:
|
|
129
|
+
return config_error
|
|
130
|
+
|
|
131
|
+
from mcp_browser_use.constants import ACTION_LOCK_TTL_SECS
|
|
132
|
+
from mcp_browser_use.locking.action_lock import (
|
|
133
|
+
_acquire_action_lock_or_error,
|
|
134
|
+
_renew_action_lock,
|
|
135
|
+
)
|
|
136
|
+
from mcp_browser_use.browser.process import ensure_process_tag
|
|
137
|
+
|
|
138
|
+
# Ensure process tag exists
|
|
139
|
+
owner = ensure_process_tag()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
err = _acquire_action_lock_or_error(owner)
|
|
144
|
+
if err:
|
|
145
|
+
return err
|
|
146
|
+
|
|
147
|
+
stop = False
|
|
148
|
+
def _beater():
|
|
149
|
+
nonlocal stop
|
|
150
|
+
while not stop:
|
|
151
|
+
_time.sleep(1.0)
|
|
152
|
+
try:
|
|
153
|
+
_renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
t = threading.Thread(target=_beater, daemon=True)
|
|
158
|
+
t.start()
|
|
159
|
+
try:
|
|
160
|
+
return func(*args, **kwargs)
|
|
161
|
+
finally:
|
|
162
|
+
stop = True
|
|
163
|
+
with contextlib.suppress(Exception):
|
|
164
|
+
t.join(timeout=0.5)
|
|
165
|
+
with contextlib.suppress(Exception):
|
|
166
|
+
_renew_action_lock(owner, ttl=ACTION_LOCK_TTL_SECS)
|
|
167
|
+
|
|
168
|
+
return wrapper
|
|
169
|
+
|
|
170
|
+
return decorator if _func is None else decorator(_func)
|
|
171
|
+
|
|
172
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers module - Compatibility layer.
|
|
3
|
+
|
|
4
|
+
This module now serves as a compatibility layer that re-exports functions
|
|
5
|
+
from the refactored modules. All actual implementations have been moved to:
|
|
6
|
+
- locking/ (file_mutex, action_lock, window_registry)
|
|
7
|
+
- browser/ (process, devtools, chrome, driver)
|
|
8
|
+
- actions/ (navigation, elements, screenshots, keyboard)
|
|
9
|
+
- utils/ (html_utils, retry, diagnostics)
|
|
10
|
+
|
|
11
|
+
The functions are re-exported here to maintain backward compatibility with
|
|
12
|
+
existing code that imports from helpers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
#region Imports
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import psutil
|
|
21
|
+
import socket
|
|
22
|
+
import shutil
|
|
23
|
+
import asyncio
|
|
24
|
+
import hashlib
|
|
25
|
+
import tempfile
|
|
26
|
+
import platform
|
|
27
|
+
import traceback
|
|
28
|
+
import subprocess
|
|
29
|
+
import contextlib
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from bs4 import BeautifulSoup
|
|
33
|
+
from typing import Callable, Optional, Tuple, Dict, Any
|
|
34
|
+
import io
|
|
35
|
+
import base64
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
#endregion Imports
|
|
40
|
+
|
|
41
|
+
#region Browser
|
|
42
|
+
import selenium
|
|
43
|
+
from selenium import webdriver
|
|
44
|
+
from selenium.webdriver.common.by import By
|
|
45
|
+
from selenium.common.exceptions import (
|
|
46
|
+
TimeoutException,
|
|
47
|
+
NoSuchWindowException,
|
|
48
|
+
StaleElementReferenceException,
|
|
49
|
+
WebDriverException,
|
|
50
|
+
ElementClickInterceptedException,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
54
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
55
|
+
#endregion
|
|
56
|
+
|
|
57
|
+
#region Imports Dotenv
|
|
58
|
+
from dotenv import load_dotenv, find_dotenv
|
|
59
|
+
load_dotenv(find_dotenv(filename=".env", usecwd=True), override=True)
|
|
60
|
+
#endregion
|
|
61
|
+
|
|
62
|
+
#region Context Integration (Phase 2)
|
|
63
|
+
# Import new modules
|
|
64
|
+
from .context import get_context, reset_context, BrowserContext
|
|
65
|
+
from .constants import (
|
|
66
|
+
ACTION_LOCK_TTL_SECS as _ACTION_LOCK_TTL_SECS,
|
|
67
|
+
ACTION_LOCK_WAIT_SECS as _ACTION_LOCK_WAIT_SECS,
|
|
68
|
+
FILE_MUTEX_STALE_SECS as _FILE_MUTEX_STALE_SECS,
|
|
69
|
+
WINDOW_REGISTRY_STALE_THRESHOLD as _WINDOW_REGISTRY_STALE_THRESHOLD,
|
|
70
|
+
MAX_SNAPSHOT_CHARS as _MAX_SNAPSHOT_CHARS,
|
|
71
|
+
START_LOCK_WAIT_SEC as _START_LOCK_WAIT_SEC,
|
|
72
|
+
RENDEZVOUS_TTL_SEC as _RENDEZVOUS_TTL_SEC,
|
|
73
|
+
ALLOW_ATTACH_ANY as _ALLOW_ATTACH_ANY,
|
|
74
|
+
)
|
|
75
|
+
from .config.environment import get_env_config as _get_env_config, profile_key as _profile_key
|
|
76
|
+
from .config.paths import get_lock_dir as _get_lock_dir
|
|
77
|
+
#endregion
|
|
78
|
+
|
|
79
|
+
#region Constants / policy parameters (Backwards Compatible - delegates to constants.py)
|
|
80
|
+
# These now delegate to constants.py but maintain the old API
|
|
81
|
+
ACTION_LOCK_TTL_SECS = _ACTION_LOCK_TTL_SECS
|
|
82
|
+
ACTION_LOCK_WAIT_SECS = _ACTION_LOCK_WAIT_SECS
|
|
83
|
+
FILE_MUTEX_STALE_SECS = _FILE_MUTEX_STALE_SECS
|
|
84
|
+
WINDOW_REGISTRY_STALE_THRESHOLD = _WINDOW_REGISTRY_STALE_THRESHOLD
|
|
85
|
+
MAX_SNAPSHOT_CHARS = _MAX_SNAPSHOT_CHARS
|
|
86
|
+
START_LOCK_WAIT_SEC = _START_LOCK_WAIT_SEC
|
|
87
|
+
RENDEZVOUS_TTL_SEC = _RENDEZVOUS_TTL_SEC
|
|
88
|
+
ALLOW_ATTACH_ANY = _ALLOW_ATTACH_ANY
|
|
89
|
+
#endregion
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#region Re-exports
|
|
94
|
+
|
|
95
|
+
# Core functions needed by decorators (internal but exported)
|
|
96
|
+
from .locking.action_lock import (
|
|
97
|
+
get_intra_process_lock, # Used by decorators
|
|
98
|
+
_acquire_action_lock_or_error, # Used by decorators
|
|
99
|
+
_renew_action_lock, # Used by decorators
|
|
100
|
+
_release_action_lock, # Used by tools
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
from .browser.process import (
|
|
104
|
+
ensure_process_tag, # Used by decorators/tools
|
|
105
|
+
make_process_tag, # Used internally
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
from .browser.driver import (
|
|
109
|
+
_ensure_driver_and_window, # Used by tools
|
|
110
|
+
_ensure_singleton_window, # Used by decorators
|
|
111
|
+
close_singleton_window, # Used by tools
|
|
112
|
+
_cleanup_own_blank_tabs, # Used by tools
|
|
113
|
+
_close_extra_blank_windows_safe, # Used by tools
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
from .actions.navigation import (
|
|
117
|
+
_wait_document_ready, # Used by tools
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
from .actions.screenshots import (
|
|
121
|
+
_make_page_snapshot, # Used by tools
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# DO NOT re-export everything else - force migration
|
|
125
|
+
# If someone needs it, they import directly from the module
|
|
126
|
+
#endregion
|
|
127
|
+
|
|
128
|
+
# Export list - Only essentials
|
|
129
|
+
__all__ = [
|
|
130
|
+
# ===== Public API =====
|
|
131
|
+
# Context
|
|
132
|
+
'get_context',
|
|
133
|
+
'reset_context',
|
|
134
|
+
'BrowserContext',
|
|
135
|
+
|
|
136
|
+
# Constants
|
|
137
|
+
'ACTION_LOCK_TTL_SECS',
|
|
138
|
+
'ACTION_LOCK_WAIT_SECS',
|
|
139
|
+
'FILE_MUTEX_STALE_SECS',
|
|
140
|
+
'WINDOW_REGISTRY_STALE_THRESHOLD',
|
|
141
|
+
'MAX_SNAPSHOT_CHARS',
|
|
142
|
+
'START_LOCK_WAIT_SEC',
|
|
143
|
+
'RENDEZVOUS_TTL_SEC',
|
|
144
|
+
'ALLOW_ATTACH_ANY',
|
|
145
|
+
|
|
146
|
+
# ===== Core Functions (Internal but needed by decorators/tools) =====
|
|
147
|
+
# Locking
|
|
148
|
+
'get_intra_process_lock',
|
|
149
|
+
'_acquire_action_lock_or_error',
|
|
150
|
+
'_renew_action_lock',
|
|
151
|
+
'_release_action_lock',
|
|
152
|
+
|
|
153
|
+
# Process
|
|
154
|
+
'ensure_process_tag',
|
|
155
|
+
'make_process_tag',
|
|
156
|
+
|
|
157
|
+
# Driver
|
|
158
|
+
'_ensure_driver_and_window',
|
|
159
|
+
'_ensure_singleton_window',
|
|
160
|
+
'close_singleton_window',
|
|
161
|
+
'_cleanup_own_blank_tabs',
|
|
162
|
+
'_close_extra_blank_windows_safe',
|
|
163
|
+
|
|
164
|
+
# Actions
|
|
165
|
+
'_wait_document_ready',
|
|
166
|
+
'_make_page_snapshot',
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
# NOTE: For all other functions, import directly from the source module:
|
|
170
|
+
# from mcp_browser_use.actions.navigation import navigate_to_url
|
|
171
|
+
# from mcp_browser_use.actions.elements import click_element
|
|
172
|
+
# from mcp_browser_use.browser.chrome import start_or_attach_chrome_from_env
|
|
173
|
+
# etc.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# mcp_browser_use/helpers_context.py
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import json as _json
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
7
|
+
from .context_pack import ContextPack, ReturnMode
|
|
8
|
+
from .cleaners import basic_prune, approx_token_count, extract_outline
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _wait_for_dom_ready(driver, timeout=15):
|
|
12
|
+
WebDriverWait(driver=driver, timeout=timeout).until(
|
|
13
|
+
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def _apply_snapshot_settle():
|
|
17
|
+
settle_ms = int(os.getenv("SNAPSHOT_SETTLE_MS", "200")) # 0 disables
|
|
18
|
+
if settle_ms > 0:
|
|
19
|
+
time.sleep(settle_ms / 1000.0)
|
|
20
|
+
|
|
21
|
+
def get_outer_html(driver) -> str:
|
|
22
|
+
_wait_for_dom_ready(driver=driver)
|
|
23
|
+
_apply_snapshot_settle()
|
|
24
|
+
# If you currently use driver.page_source, keep that; both are fine.
|
|
25
|
+
return driver.execute_script("return document.documentElement.outerHTML")
|
|
26
|
+
|
|
27
|
+
def take_screenshot(driver, path: str):
|
|
28
|
+
_wait_for_dom_ready(driver=driver)
|
|
29
|
+
_apply_snapshot_settle()
|
|
30
|
+
driver.save_screenshot(path)
|
|
31
|
+
|
|
32
|
+
def pack_snapshot(
|
|
33
|
+
*,
|
|
34
|
+
window_tag: Optional[str],
|
|
35
|
+
url: Optional[str],
|
|
36
|
+
title: Optional[str],
|
|
37
|
+
raw_html: Optional[str],
|
|
38
|
+
return_mode: str,
|
|
39
|
+
cleaning_level: int,
|
|
40
|
+
token_budget: Optional[int],
|
|
41
|
+
text_offset: Optional[int] = None,
|
|
42
|
+
html_offset: Optional[int] = None,
|
|
43
|
+
) -> ContextPack:
|
|
44
|
+
cp = ContextPack(
|
|
45
|
+
window_tag=window_tag,
|
|
46
|
+
url=url,
|
|
47
|
+
title=title,
|
|
48
|
+
cleaning_level_applied=cleaning_level,
|
|
49
|
+
snapshot_mode=return_mode,
|
|
50
|
+
tokens_budget=token_budget,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
html = raw_html or ""
|
|
54
|
+
cleaned_html, pruned_counts = basic_prune(html=html, level=cleaning_level)
|
|
55
|
+
cp.pruned_counts = pruned_counts
|
|
56
|
+
|
|
57
|
+
if return_mode == ReturnMode.OUTLINE:
|
|
58
|
+
outline = extract_outline(html=cleaned_html)
|
|
59
|
+
cp.outline_present = True
|
|
60
|
+
cp.outline = [
|
|
61
|
+
# convert dict -> dataclass-ish dict; leaving as dict is fine for now
|
|
62
|
+
o for o in outline
|
|
63
|
+
]
|
|
64
|
+
cp.approx_tokens = approx_token_count(text=" ".join([o["text"] for o in outline]))
|
|
65
|
+
return cp
|
|
66
|
+
|
|
67
|
+
if return_mode == ReturnMode.HTML:
|
|
68
|
+
# Apply html_offset if specified (for pagination through large HTML content)
|
|
69
|
+
if html_offset and html_offset > 0:
|
|
70
|
+
cleaned_html = cleaned_html[html_offset:]
|
|
71
|
+
|
|
72
|
+
# Respect token budget by truncating cleaned_html conservatively
|
|
73
|
+
if token_budget:
|
|
74
|
+
# Convert tokens -> chars budget ~4 chars/token
|
|
75
|
+
char_budget = token_budget * 4
|
|
76
|
+
if len(cleaned_html) > char_budget:
|
|
77
|
+
cleaned_html = cleaned_html[:char_budget]
|
|
78
|
+
cp.hard_capped = True
|
|
79
|
+
cp.html = cleaned_html
|
|
80
|
+
cp.approx_tokens = approx_token_count(text=cleaned_html)
|
|
81
|
+
return cp
|
|
82
|
+
|
|
83
|
+
if return_mode == ReturnMode.TEXT:
|
|
84
|
+
# Very naive visible text extraction through soup.get_text()
|
|
85
|
+
try:
|
|
86
|
+
from bs4 import BeautifulSoup
|
|
87
|
+
txt = BeautifulSoup(cleaned_html, "html.parser").get_text("\n", strip=True)
|
|
88
|
+
except Exception:
|
|
89
|
+
txt = ""
|
|
90
|
+
|
|
91
|
+
# Apply text_offset if specified (for pagination through large content)
|
|
92
|
+
if text_offset and text_offset > 0:
|
|
93
|
+
txt = txt[text_offset:]
|
|
94
|
+
|
|
95
|
+
if token_budget:
|
|
96
|
+
char_budget = token_budget * 4
|
|
97
|
+
if len(txt) > char_budget:
|
|
98
|
+
txt = txt[:char_budget]
|
|
99
|
+
cp.hard_capped = True
|
|
100
|
+
cp.text = txt
|
|
101
|
+
cp.approx_tokens = approx_token_count(text=txt)
|
|
102
|
+
return cp
|
|
103
|
+
|
|
104
|
+
# Fallback to outline
|
|
105
|
+
outline = extract_outline(html=cleaned_html)
|
|
106
|
+
cp.outline_present = True
|
|
107
|
+
cp.outline = [o for o in outline]
|
|
108
|
+
cp.approx_tokens = approx_token_count(text=" ".join([o["text"] for o in outline]))
|
|
109
|
+
return cp
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pack_from_snapshot_dict(
|
|
114
|
+
snapshot: dict,
|
|
115
|
+
window_tag: Optional[str],
|
|
116
|
+
return_mode: str,
|
|
117
|
+
cleaning_level: int,
|
|
118
|
+
token_budget: Optional[int],
|
|
119
|
+
text_offset: Optional[int] = None,
|
|
120
|
+
html_offset: Optional[int] = None,
|
|
121
|
+
):
|
|
122
|
+
"""
|
|
123
|
+
Build a ContextPack object from a raw snapshot dict and packing controls.
|
|
124
|
+
|
|
125
|
+
Applies structural/content pruning and optional truncation to fit the `token_budget`,
|
|
126
|
+
derives the selected representation (outline/text/html/dompaths/mixed), and attaches
|
|
127
|
+
metadata including `window_tag`.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
snapshot: Raw snapshot dict (e.g., {"url", "title", "html", ...}).
|
|
131
|
+
window_tag: Optional identifier for the active window/tab.
|
|
132
|
+
return_mode: Target representation to materialize in the ContextPack.
|
|
133
|
+
{"outline", "text", "html", "dompaths", "mixed"}
|
|
134
|
+
cleaning_level: Structural/content cleaning intensity (0–3).
|
|
135
|
+
token_budget: Optional approximate token cap for the returned snapshot.
|
|
136
|
+
text_offset: Optional character offset to skip at the start of text (for pagination).
|
|
137
|
+
Only used when return_mode="text".
|
|
138
|
+
html_offset: Optional character offset to skip at the start of HTML (for pagination).
|
|
139
|
+
Only used when return_mode="html".
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ContextPack: The structured envelope ready for JSON serialization.
|
|
143
|
+
|
|
144
|
+
Notes:
|
|
145
|
+
- **Processing order**:
|
|
146
|
+
1. Clean HTML (remove noise based on cleaning_level)
|
|
147
|
+
2. Apply offset (skip first N chars)
|
|
148
|
+
3. Apply token_budget (truncate to fit)
|
|
149
|
+
|
|
150
|
+
- **Offset behavior**:
|
|
151
|
+
- Applied to cleaned content, not raw HTML
|
|
152
|
+
- Character-based, not token-based
|
|
153
|
+
- If offset exceeds content length, returns empty string
|
|
154
|
+
- Use consistent cleaning_level across paginated calls
|
|
155
|
+
|
|
156
|
+
- Consider computing a `page_fingerprint` (e.g., sha256 of cleaned html) to assist
|
|
157
|
+
agents in cheap change detection between steps.
|
|
158
|
+
"""
|
|
159
|
+
return pack_snapshot(
|
|
160
|
+
window_tag=window_tag,
|
|
161
|
+
url=snapshot.get("url"),
|
|
162
|
+
title=snapshot.get("title"),
|
|
163
|
+
raw_html=snapshot.get("html"),
|
|
164
|
+
return_mode=return_mode,
|
|
165
|
+
cleaning_level=cleaning_level,
|
|
166
|
+
token_budget=token_budget,
|
|
167
|
+
text_offset=text_offset,
|
|
168
|
+
html_offset=html_offset,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def to_context_pack(result_json: str, return_mode: str, cleaning_level: int, token_budget=1000, text_offset: Optional[int] = None, html_offset: Optional[int] = None) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Convert a helper's raw JSON result into a JSON-serialized ContextPack envelope.
|
|
175
|
+
|
|
176
|
+
Parses a helper response (typically including a "snapshot" dict and auxiliary fields),
|
|
177
|
+
normalizes `return_mode`, fetches current page metadata, and produces a size-controlled,
|
|
178
|
+
structured ContextPack. Any non-snapshot fields from the helper are surfaced under
|
|
179
|
+
the ContextPack's auxiliary section (e.g., `mixed`). Helper-reported errors (e.g.,
|
|
180
|
+
ok=false) are surfaced in `errors`.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
result_json: JSON string returned by a helper call (must parse to a dict).
|
|
184
|
+
return_mode: Desired snapshot representation {"outline","text","html","dompaths","mixed"}.
|
|
185
|
+
cleaning_level: Structural/content cleaning intensity (0–3).
|
|
186
|
+
token_budget: Approximate token cap for the returned snapshot. Should usually be 5_000 or lower.
|
|
187
|
+
text_offset: Optional character offset for text mode pagination.
|
|
188
|
+
html_offset: Optional character offset for html mode pagination.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
str: JSON-serialized ContextPack.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
TypeError: If `result_json` is not valid JSON or is not a dict after parsing.
|
|
195
|
+
ValueError: If `return_mode` is invalid (normalized internally to a default).
|
|
196
|
+
"""
|
|
197
|
+
# Import here to avoid circular dependency at module load time
|
|
198
|
+
import mcp_browser_use.helpers as helpers
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
obj = _json.loads(result_json)
|
|
202
|
+
except Exception:
|
|
203
|
+
raise TypeError(f"helper returned non-JSON: {type(result_json)}")
|
|
204
|
+
|
|
205
|
+
# Normalize/validate return_mode
|
|
206
|
+
mode = (return_mode or "outline").lower()
|
|
207
|
+
if mode not in {"html", "text", "outline", "dompaths", "mixed"}:
|
|
208
|
+
mode = "outline"
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
meta = await helpers.get_current_page_meta()
|
|
212
|
+
except Exception:
|
|
213
|
+
meta = {"url": None, "title": None, "window_tag": None}
|
|
214
|
+
|
|
215
|
+
snap = obj.get("snapshot")
|
|
216
|
+
if not isinstance(snap, dict):
|
|
217
|
+
snap = {"url": meta.get("url"), "title": meta.get("title"), "html": ""}
|
|
218
|
+
|
|
219
|
+
cp = pack_from_snapshot_dict(
|
|
220
|
+
snapshot=snap,
|
|
221
|
+
window_tag=meta.get("window_tag"),
|
|
222
|
+
return_mode=mode,
|
|
223
|
+
cleaning_level=cleaning_level,
|
|
224
|
+
token_budget=token_budget,
|
|
225
|
+
text_offset=text_offset,
|
|
226
|
+
html_offset=html_offset,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Add warning if token budget is too high
|
|
230
|
+
if token_budget and token_budget > 10_000:
|
|
231
|
+
cp.errors.append({
|
|
232
|
+
"type": "warning",
|
|
233
|
+
"summary": "High token budget detected",
|
|
234
|
+
"details": {
|
|
235
|
+
"requested_token_budget": token_budget,
|
|
236
|
+
"message": (
|
|
237
|
+
f"You requested a token budget of {token_budget} tokens, which may clog your context. "
|
|
238
|
+
"Consider using the extract_elements tool with structured extraction (MODE 2) "
|
|
239
|
+
"to extract only the specific data you need. This can reduce token usage by 90%+ "
|
|
240
|
+
"while getting more precise results. "
|
|
241
|
+
"Example: extract_elements(container_selector='div.product', fields=[...], max_items=50)"
|
|
242
|
+
),
|
|
243
|
+
"recommendation": "Use extract_elements tool for targeted data extraction instead of large snapshots"
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
# Surface errors in a first-class place
|
|
248
|
+
if obj.get("ok") is False:
|
|
249
|
+
try:
|
|
250
|
+
cp.errors.append({
|
|
251
|
+
"type": obj.get("error") or "error",
|
|
252
|
+
"summary": obj.get("summary"),
|
|
253
|
+
"details": {k: v for k, v in obj.items() if k != "snapshot"},
|
|
254
|
+
})
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
leftovers = {k: v for k, v in obj.items() if k != "snapshot"}
|
|
259
|
+
cp.mixed = leftovers
|
|
260
|
+
|
|
261
|
+
return _json.dumps(cp, default=lambda o: getattr(o, "__dict__", repr(o)), ensure_ascii=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Multi-agent coordination and locking."""
|