From 9cc069881c858d20a46906e67b815c100d348b15 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Tue, 1 Sep 2026 16:32:20 -0400 Subject: [PATCH 1/4] Update the MCP servers --- cdp_server.py | 146 ++++++++++++++++++++++++++++------------------- driver_server.py | 91 +++++++++++++++++++++-------- sb_server.py | 112 +++++++++++++++++++++++++++++------- 3 files changed, 243 insertions(+), 106 deletions(-) diff --git a/cdp_server.py b/cdp_server.py index 930bfa2..ec0d524 100644 --- a/cdp_server.py +++ b/cdp_server.py @@ -32,14 +32,8 @@ - Use wait_for when the agent needs to wait for a condition to become true. - Use assert_that when the agent needs to verify an expected condition and treat failure as an assertion error. -- Use click/fill_input/select_option/hover/act_on_element for interactions. - -Note on elements: CDP-mode element objects (from find_element/find_all) are -live handles with their own methods (.click(), .get_html(), ...) that can't -cross the MCP boundary as stateful objects. Tools here resolve elements -immediately to plain dicts (tag, text, html) rather than returning handles. -To act on one of several matches, use click(selector, nth=...) rather than -find + click. +- Use click/fill_input/select_option/hover_with_action/focus_on for + interactions and element positioning. """ from __future__ import annotations import atexit @@ -708,54 +702,84 @@ def click( @mcp.tool() @handle_sb_errors -def hover( - selector: str, - then_click_selector: str | None = None, +def hover_with_action( + selector1: str, + selector2: str | None = None, + action: Literal[ + "none", + "click", + "drag_and_drop", + ] = "none", ) -> str: - """Hover over an element, optionally clicking an element revealed by hover. + """Hover over an element, optionally click another element, or drag-&-drop. - Use this for menus, dropdowns, tooltips, or other interfaces where an - element must first be hovered before its target becomes available. + Use this tool for hover interactions, hover-triggered menus, and + drag-and-drop operations. Args: - selector: Element to hover over. - then_click_selector: Optional element to click after the hover. - Useful for a submenu item or dropdown option revealed by hover. + selector1: + The primary element selector. - Returns: - A confirmation describing the hover/click operation. - """ - sb = _get_sb() + For action="none", this is the element to hover over. - if then_click_selector: - sb.hover_and_click(selector, then_click_selector) - return f"Hovered {selector} and clicked {then_click_selector}" + For action="click", this is the element to hover over before + clicking selector2. - sb.hover_element(selector) - return f"Hovered {selector}" + For action="drag_and_drop", this is the draggable source element. + selector2: + The secondary element selector. -@mcp.tool() -@handle_sb_errors -def drag_and_drop( - source_selector: str, - target_selector: str, -) -> str: - """Drag a draggable element and drop it onto another element. + Required for action="click", where it identifies the element + revealed or targeted after hovering selector1. - Drag-and-drop is performed through SeleniumBase's CDP browser controls, - simulating the pointer and mouse events expected by web applications. + Required for action="drag_and_drop", where it identifies the + destination/drop target. - Args: - source_selector: CSS selector identifying the draggable source. - target_selector: CSS selector identifying the drop target. + Not used for action="none". + + action: + - "none": Hover over selector1 only. + - "click": Hover over selector1, then click selector2. + - "drag_and_drop": Drag selector1 and drop it onto selector2. + + Returns: + A confirmation describing the performed operation. + + Tool selection: + - Simple hover -> action="none". + - Hover over one element and then click another -> action="click". + - Drag one element onto another -> action="drag_and_drop". + + Notes: + For action="click", selector1 is the hover target and selector2 is + the click target. - The simulated interaction includes events such as pointerdown, - mousedown, dragstart, dragenter, dragover, drop, dragend, mouseup, and - pointerup. + For action="drag_and_drop", selector1 is the source and selector2 + is the destination. """ - _get_sb().drag_and_drop(source_selector, target_selector) - return f"Dragged {source_selector} onto {target_selector}" + sb = _get_sb() + + if action == "none": + sb.hover_element(selector1) + return f"Hovered {selector1}" + + if action == "click": + if selector2 is None: + return "Error: action='click' requires selector2." + sb.hover_and_click(selector1, selector2) + return f"Hovered {selector1} and clicked {selector2}" + + if action == "drag_and_drop": + if selector2 is None: + return "Error: action='drag_and_drop' requires selector2." + sb.drag_and_drop(selector1, selector2) + return f"Dragged {selector1} onto {selector2}" + + return ( + f"Error: unknown action '{action}'. " + "Use 'none', 'click', or 'drag_and_drop'." + ) @mcp.tool() @@ -860,50 +884,52 @@ def select_option( @mcp.tool() @handle_sb_errors -def act_on_element( +def focus_on( selector: str, action: Literal[ + "scroll_to_element", "focus", "highlight", - "scroll_into_view", - ] = "focus", + ] = "scroll_to_element", ) -> str: - """Perform a non-click positioning or debugging action on an element. + """Scroll to, focus, or highlight an element. - Use this tool when an element needs to be focused, highlighted for human - observation/debugging, or scrolled into the viewport. + Use this tool when an element needs to be brought into view, focused for + keyboard interaction, or highlighted for debugging/demonstration. This tool does NOT click, type into, select from, hover over, or otherwise activate the element. Args: selector: CSS selector or SeleniumBase selector identifying the target. + action: + - "scroll_to_element": Scroll the page until the element is in + the current viewport. This is the default action. - "focus": Move keyboard focus to the element. - "highlight": Temporarily highlight the element for debugging or demonstration. This can affect timing and may reduce stealth. - - "scroll_into_view": Scroll the page until the element is in the - current viewport. Tool selection: + - Bring an element into view -> use focus_on with the default action. + - Focus an element -> use focus_on(action="focus"). + - Highlight element for debugging -> use focus_on(action="highlight"). - Click -> use click. - Type into a form control -> use fill_input. - - Hover -> use hover. - - Focus, highlight, or scroll without activating -> use - act_on_element. + - Hover -> use hover_with_action. """ sb = _get_sb() - if action == "focus": + if action == "scroll_to_element": + sb.scroll_into_view(selector) + elif action == "focus": sb.find_element(selector).focus() elif action == "highlight": sb.highlight(selector) - elif action == "scroll_into_view": - sb.scroll_into_view(selector) else: return ( f"Error: unknown action '{action}'. " - "Use 'focus', 'highlight', or 'scroll_into_view'." + "Use 'scroll_to_element', 'focus', or 'highlight'." ) return f"{action} done for {selector}" @@ -1221,8 +1247,8 @@ def scroll( up/down scrolling. For example, amount=25 scrolls approximately one quarter of the viewport height. - Use act_on_element(action="scroll_into_view") when the goal is to reveal - a specific element rather than scroll the page by a relative amount. + Use focus_on(action="scroll_to_element") when the goal is to reveal a + specific element rather than scroll the page by a relative amount. """ sb = _get_sb() diff --git a/driver_server.py b/driver_server.py index 95b6fb2..ed11f11 100644 --- a/driver_server.py +++ b/driver_server.py @@ -12,7 +12,7 @@ import atexit import sys from functools import wraps -from typing import Any +from typing import Any, Literal from mcp.server import MCPServer from seleniumbase import Driver @@ -48,41 +48,79 @@ def wrapper(*args, **kwargs): @mcp.tool() @handle_sb_errors def start_browser( - browser: str = "chrome", - headless: bool = False, + browser: Literal["chrome", "edge", "firefox", "chromium"] = "chrome", + headless: bool | None = None, uc: bool = True, incognito: bool = False, guest_mode: bool = False, - proxy: str | None = None, ad_block: bool = False, + proxy: str | None = None, ) -> str: """Start a new browser session. Must be called before any other tool. - Args: - headless: Run without a visible window. Set False if you need to - watch the browser or if a site blocks headless clients. - browser: "chrome", "edge", or "firefox". + browser: "chrome", "edge", "firefox", or "chromium". + headless: Controls whether the browser runs without a visible window. + If True, always run headless. If False, always run headed. + If omitted (None), the default depends on the operating system: + Linux defaults to headless because MCP/server environments + commonly do not have a graphical desktop, while Windows and macOS + default to headed so that a visible browser window is available. + Use True or False to explicitly override the OS-specific default + on any operating system. uc: Undetected-chromedriver mode, useful for sites with bot detection. - incognito: Launch in a private/incognito window. + (The `uc` option is for Chrome/Chromium, only!) + incognito: Launch Chrome/Chromium in incognito mode. + guest: Launch Chrome/Chromium in guest mode. + (Do not combine this with incognito=True.) + ad_block: Enable SeleniumBase's basic ad-blocking functionality. + proxy: Optional proxy server. Examples include + "SERVER:PORT" or "USER:PASS@SERVER:PORT". """ global _driver if _driver is not None: return ( "A browser session is already running. Call close_browser first." ) - _driver = Driver( - browser=browser, - headless=headless, - uc=uc, - incognito=incognito, - guest_mode=guest_mode, - proxy=proxy, - ad_block=ad_block, - ) - return ( - f"Started Driver() session with browser={browser}, " - f"headless={headless}, uc={uc}." - ) + + # OS-specific default: + # - Linux: headless by default for server/container compatibility. + # - Windows/macOS: headed by default for interactive desktop use. + # - Explicit True/False always overrides the OS default. + if headless is None: + headless = sys.platform.startswith("linux") + + use_chromium = False + if browser == "chromium": + use_chromium = True + browser = "chrome" + + try: + _driver = Driver( + browser=browser, + headless=headless, + use_chromium=use_chromium, + uc=uc, + incognito=incognito, + guest_mode=guest_mode, + ad_block=ad_block, + proxy=proxy, + ) + return ( + f"Started Driver() session with browser={browser}, " + f"headless={headless}, uc={uc}." + ) + except Exception as e: + if _driver is not None: + try: + _driver.quit() + except Exception: + pass + _driver = None + + return ( + f"Error starting browser: " + f"{e.__class__.__name__} - {str(e).strip()}" + ) @mcp.tool() @@ -196,7 +234,7 @@ def is_element_visible(selector: str) -> bool: @mcp.tool() @handle_sb_errors -def click(selector: str, timeout: int = 7) -> str: +def click(selector: str, timeout: int | float | None = 7) -> str: """Click an element matched by the given selector. Raises an exception if the element isn't found within the timeout.""" d = _get_driver() @@ -207,7 +245,10 @@ def click(selector: str, timeout: int = 7) -> str: @mcp.tool() @handle_sb_errors def type_text( - selector: str, text: str, clear_first: bool = True, timeout: int = 7 + selector: str, + text: str, + clear_first: bool = True, + timeout: int | float | None = 7, ) -> str: """Type text into an input field / textarea. Raises an exception if the element isn't found within the timeout. @@ -256,7 +297,7 @@ def select_option_by_index(dropdown_selector: str, option: str) -> str: @mcp.tool() @handle_sb_errors -def wait_for_element(selector: str, timeout: int = 10) -> str: +def wait_for_element(selector: str, timeout: int | float | None = 10) -> str: """Wait until an element matched by a CSS selector appears. Raises an exception if the element isn't found within the given timeout.""" _get_driver().wait_for_element(selector, timeout=timeout) diff --git a/sb_server.py b/sb_server.py index 591a26d..8d2a6b9 100644 --- a/sb_server.py +++ b/sb_server.py @@ -26,7 +26,7 @@ import atexit import sys from functools import wraps -from typing import Any +from typing import Any, Literal from mcp.server import MCPServer from seleniumbase import SB @@ -63,32 +63,54 @@ def wrapper(*args, **kwargs): @mcp.tool() @handle_sb_errors def start_browser( - browser: str = "chrome", - headless: bool = False, + browser: Literal["chrome", "edge", "firefox", "chromium"] = "chrome", + headless: bool | None = None, uc: bool = True, incognito: bool = False, guest_mode: bool = False, - proxy: str | None = None, ad_block: bool = False, + proxy: str | None = None, ) -> str: """Start a new SB() session. Must be called before any other tool. Args: - browser: "chrome", "edge", or "firefox". - headless: Run without a visible window. + browser: "chrome", "edge", "firefox", or "chromium". + headless: Controls whether the browser runs without a visible window. + If True, always run headless. If False, always run headed. + If omitted (None), the default depends on the operating system: + Linux defaults to headless because MCP/server environments + commonly do not have a graphical desktop, while Windows and macOS + default to headed so that a visible browser window is available. + Use True or False to explicitly override the OS-specific default + on any operating system. uc: Undetected-Chromedriver (UC Mode) — evades bot detection. incognito: Launch in a private/incognito window. guest_mode: Launch in Chrome guest mode. - proxy: Proxy string, e.g. "USER:PASS@SERVER:PORT" or "SERVER:PORT". ad_block: Block ads. + proxy: Proxy string, e.g. "USER:PASS@SERVER:PORT" or "SERVER:PORT". """ global _sb_context, _sb if _sb is not None: return ( "A browser session is already running. Call close_browser first." ) + + # OS-specific default: + # - Linux: headless by default for server/container compatibility. + # - Windows/macOS: headed by default for interactive desktop use. + # - Explicit True/False always overrides the OS default. + if headless is None: + headless = sys.platform.startswith("linux") + + use_chromium = False + if browser == "chromium": + use_chromium = True + browser = "chrome" + kwargs: dict[str, Any] = { "browser": browser, "headless": headless, "test": False } + if use_chromium: + kwargs["use_chromium"] = True if uc: kwargs["uc"] = True if incognito: @@ -99,27 +121,74 @@ def start_browser( kwargs["proxy"] = proxy if ad_block: kwargs["ad_block"] = True - _sb_context = SB(**kwargs) - _sb = _sb_context.__enter__() - return ( - f"Started SB() session with browser={browser}, " - f"headless={headless}, uc={uc}." - ) + try: + _sb_context = SB(**kwargs) + _sb = _sb_context.__enter__() + return ( + f"Started SB() session with browser={browser}, " + f"headless={headless}, uc={uc}." + ) + except Exception as e: + if _sb is not None: + try: + _sb.quit() + except Exception: + pass + _sb_context = None + _sb = None + return ( + f"Error starting browser: " + f"{e.__class__.__name__} - {str(e).strip()}" + ) @mcp.tool() @handle_sb_errors def close_browser() -> str: - """Close the browser and end the session.""" global _sb_context, _sb if _sb_context is None: return "No browser session was running." - _sb_context.__exit__(None, None, None) - _sb_context = None - _sb = None + try: + _sb_context.__exit__(None, None, None) + finally: + _sb_context = None + _sb = None return "Browser closed." +# --------------------------------------------------------------------------- +# Page information +# --------------------------------------------------------------------------- + +@mcp.tool() +@handle_sb_errors +def browser_status() -> dict: + """Return basic information about the current browser session.""" + if _sb is None: + return { + "running": False, + } + + return { + "running": True, + "url": _sb.get_current_url(), + "title": _sb.get_title(), + } + + +@mcp.tool() +@handle_sb_errors +def page_snapshot() -> dict: + """Return compact information about the current page.""" + sb = _get_sb() + + return { + "url": sb.get_current_url(), + "title": sb.get_title(), + "text": sb.get_text("body"), + } + + # --------------------------------------------------------------------------- # Navigation # --------------------------------------------------------------------------- @@ -891,13 +960,14 @@ def sleep(seconds: float) -> str: def _cleanup_browser(): - global _sb - if _sb is not None: + global _sb_context, _sb + if _sb_context is not None: try: - _sb.quit() + _sb_context.__exit__(None, None, None) except Exception: pass - _sb = None + _sb_context = None + _sb = None def main(): From bd25a7a4ab0ec28322e79e1f6e54c1a50862f252 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Tue, 1 Sep 2026 16:32:57 -0400 Subject: [PATCH 2/4] Refresh Python dependencies --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8169d9a..8226379 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -seleniumbase[mcp]>=4.53.3 +seleniumbase[mcp]>=4.53.4 mcp[cli]>=2.1.1,<3.0.0 diff --git a/setup.py b/setup.py index 36d50ed..a5ec4c7 100755 --- a/setup.py +++ b/setup.py @@ -140,7 +140,7 @@ ], python_requires=">=3.10", install_requires=[ - "seleniumbase[mcp]>=4.53.3", + "seleniumbase[mcp]>=4.53.4", "mcp[cli]>=2.1.1,<3.0.0", ], extras_require={ From 4191735ef6167312a5158dcca9aee625bc6bcece Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Tue, 1 Sep 2026 16:33:10 -0400 Subject: [PATCH 3/4] Update the ReadMe --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4dd1403..2d7fd67 100644 --- a/README.md +++ b/README.md @@ -196,19 +196,19 @@ in the loop at all. Reference: ### Tool groups -| Group | Examples | -|---|---| -| Session | `start_browser(url, headless, incognito, guest, proxy, ad_block)`, `close_browser` | -| Navigation | `navigate`, `reload_page`, `go_back`/`go_forward`, `get_current_url`, `get_title` | -| Finding & reading | `find_element_info`, `find_all_info`, `get_text`, `get_html_source`, `get_element_attribute(s)`, `is_element_present/visible` | -| Interacting | `click`, `click_if_visible`, `click_visible_elements`, `type_text`, `send_keys`, `set_value`, `select_option_by_text/value/index`, `nested_click` | -| Waiting | `wait_for_element_present`, `wait_for_element_visible/not_visible/absent`, `wait_for_text` | -| Assertions | `assert_element`, `assert_text`, `assert_exact_text`, `assert_title`, `assert_url(_contains)` | -| Cookies & storage | `get_all_cookies`, `save_cookies`/`load_cookies`, `get/set_local_storage_item`, `get/set_session_storage_item` | -| Scrolling | `scroll_into_view`, `scroll_to_top/bottom`, `scroll_up/down` | -| Tabs & windows | `open_new_tab`, `switch_to_tab`/`switch_to_newest_tab`, `close_active_tab`, `maximize`/`minimize`, `get/set_window_rect` | -| Captcha | `solve_captcha` | -| Output | `save_screenshot`, `save_page_source`, `save_as_pdf`, `evaluate` | +| Group | Tool(s) | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Session | `start_browser(url, headless, use_chromium, browser_executable_path, incognito, guest, ad_block, proxy)`, `close_browser` | +| Navigation | `navigate`, `navigate_history(action: back/forward/reload)`, `get_page_info` (running status, url, title, origin, user agent, history in one call) | +| Finding & reading | `find_elements(selector, timeout, include_html)`, `get_content(selector, output_format: text/html/urls, include_shadow_dom)`, `get_attributes`, `check_state(check: present/visible/count/text_visible)` | +| Interacting | `click(selector, nth, all_matches, only_if_visible, parent_selector, timeout, scroll)`, `hover_with_action(selector1, selector2, action: none/click/drag_and_drop)`, `fill_input(mode: type/append/set_value/fast_type/clear)`, `select_option(by: text/value/index)`, `focus_on(action: scroll_to_element/focus/highlight)` | +| Waiting | `wait_for(state: present/visible/not_visible/absent, text)` | +| Assertions | `assert_that(check: element_present/element_visible/text/title/url/url_contains)` | +| Cookies & storage | `manage_cookies(action: get_all/clear/save/load)`, `manage_storage(storage: local/session, action: get/set)` | +| Scrolling | `scroll(direction: up/down/top/bottom, amount)` | +| Windows & tabs | `manage_window(action: get_rect/set_rect/maximize/minimize)`, `manage_tabs(action: list/open/switch/switch_newest/close_active)` | +| Captcha | `solve_captcha` | +| Output & misc | `save_output(format: screenshot/html/pdf)`, `run_javascript`, `wait_seconds` | ### CDP-specific design notes From d048053a80d2cb836b323fb9c7b24fb26e3822ab Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Tue, 1 Sep 2026 16:34:06 -0400 Subject: [PATCH 4/4] Version 1.2.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a5ec4c7..5b93103 100755 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( name="seleniumbase-mcp", - version="1.2.1", + version="1.2.2", description="MCP servers exposing SeleniumBase as tools for MCP clients.", long_description=long_description, long_description_content_type="text/markdown",