From 9d27adc5e7d329bbe9b343fe93e49e675338b4a8 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Sun, 30 Aug 2026 00:54:57 -0400 Subject: [PATCH 1/4] Update the MCP servers --- cdp_server.py | 302 +++++++++++++++++++++++++++++++++++++---------- driver_server.py | 144 ++++++++++++++++------ sb_server.py | 223 +++++++++++++++++++++++++++------- 3 files changed, 528 insertions(+), 141 deletions(-) diff --git a/cdp_server.py b/cdp_server.py index 1e145d7..7a26c21 100644 --- a/cdp_server.py +++ b/cdp_server.py @@ -10,8 +10,8 @@ Reference: github.com/seleniumbase/SeleniumBase/blob/master/help_docs/cdp_mode_methods.md -Model: one persistent `sb_cdp.Chrome` session per server process. Call -start_browser once, drive it with the other tools, then close_browser. +Model: One persistent `sb_cdp.Chrome` session per server process. +Call start_browser once; drive it with the other tools; then close_browser. 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 @@ -23,6 +23,7 @@ from __future__ import annotations import atexit import sys +from functools import wraps from typing import Any from mcp.server import MCPServer from seleniumbase import sb_cdp @@ -38,6 +39,20 @@ def _get_sb() -> sb_cdp.CDPMethods: return _sb +def handle_sb_errors(func): + """Catches SeleniumBase errors and surfaces them as descriptive strings + so the LLM agent can read them and self-correct.""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_type = e.__class__.__name__ + error_msg = str(e).strip() + return f"Error in {func.__name__}: {error_type} - {error_msg}" + return wrapper + + # --------------------------------------------------------------------------- # Session lifecycle # --------------------------------------------------------------------------- @@ -54,7 +69,6 @@ def start_browser( """Launch a Pure CDP Mode browser session. Must be called before any other tool. The browser is driven entirely over CDP (no WebDriver), which is SeleniumBase's most stealth/bot-detection-resistant mode. - Args: url: Optional URL to open immediately on launch. headless: Run without a visible window. @@ -77,8 +91,17 @@ def start_browser( kwargs["proxy"] = proxy if ad_block: kwargs["ad_block"] = True - _sb = sb_cdp.Chrome(url, **kwargs) - return f"Started Pure CDP Mode browser (url={url!r}, headless={headless})" + try: + _sb = sb_cdp.Chrome(url, **kwargs) + return ( + f"Started Pure CDP Mode browser " + f"(url={url!r}, headless={headless})" + ) + except Exception as e: + return ( + f"Error starting browser: " + f"{e.__class__.__name__} - {str(e).strip()}" + ) @mcp.tool() @@ -87,7 +110,10 @@ def close_browser() -> str: global _sb if _sb is None: return "No browser session was running." - _sb.quit() + try: + _sb.quit() + except Exception: + pass _sb = None return "Browser closed." @@ -97,52 +123,70 @@ def close_browser() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def navigate(url: str) -> str: - """Navigate to a URL.""" + """Navigate to the given URL in the web browser. + If the URL doesn't start with a protocol (eg: `https://`), + then `https://` is automatically prefixed in before navigation. + Waits until the initial HTML document is fully parsed and loaded. + New pages visited will show up in browser navigation history. + If the URL is invalid or the page can't load due to an issue, + then the corresponding errors will be raised.""" _get_sb().get(url) return f"Navigated to {url}" @mcp.tool() +@handle_sb_errors def reload_page(ignore_cache: bool = True) -> str: - """Reload the current page.""" + """Reload the current page. + Same as clicking the Reload button in the web browser. + By default, ignores the browser cache on reload.""" _get_sb().reload(ignore_cache=ignore_cache) return "Page reloaded." @mcp.tool() +@handle_sb_errors def go_back() -> str: - """Go back one page in browser history.""" + """Go back one page in browser history. + Same as clicking the Back button in the web browser.""" _get_sb().go_back() return "Navigated back." @mcp.tool() +@handle_sb_errors def go_forward() -> str: - """Go forward one page in browser history.""" + """Go forward one page in browser history. + Same as clicking the Forward button in the web browser.""" _get_sb().go_forward() return "Navigated forward." @mcp.tool() +@handle_sb_errors def get_navigation_history() -> Any: """Get the browser's navigation history.""" return _get_sb().get_navigation_history() @mcp.tool() +@handle_sb_errors def get_current_url() -> str: """Get the URL of the current page.""" return _get_sb().get_current_url() @mcp.tool() +@handle_sb_errors def get_title() -> str: """Get the title of the current page.""" return _get_sb().get_title() @mcp.tool() +@handle_sb_errors def get_origin() -> str: """Get the origin (scheme + host) of the current page.""" return _get_sb().get_origin() @@ -153,18 +197,17 @@ def get_origin() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def find_element_info( selector: str, best_match: bool = False, timeout: int | None = None -) -> dict: +) -> dict | str: """Find one element and return its tag name, text, and outer HTML. - Args: selector: CSS selector, or text to search for (CDP mode can match elements by visible text as well as by selector). best_match: When matching by text and multiple elements qualify, pick the one whose text length is closest to the search text. - timeout: Seconds to wait for the element to appear. - """ + timeout: Seconds to wait for the element to appear.""" el = _get_sb().find_element( selector, best_match=best_match, timeout=timeout ) @@ -172,68 +215,85 @@ def find_element_info( @mcp.tool() -def find_all_info(selector: str, timeout: int | None = None) -> list[dict]: +@handle_sb_errors +def find_all_info( + selector: str, timeout: int | None = None +) -> list[dict] | str: """Find all matching elements and return tag name + text for each.""" els = _get_sb().find_all(selector, timeout=timeout) return [{"tag_name": e.tag_name, "text": e.text} for e in els] @mcp.tool() +@handle_sb_errors def get_text(selector: str = "body") -> str: - """Get the visible text within an element (default: whole page body).""" + """Get the visible text within an element (default: whole page body). + Raises an exception if the element isn't found within the default timeout. + """ return _get_sb().get_text(selector) @mcp.tool() +@handle_sb_errors def get_html_source(include_shadow_dom: bool = True) -> str: """Get the full HTML source of the current page.""" return _get_sb().get_page_source(include_shadow_dom=include_shadow_dom) @mcp.tool() +@handle_sb_errors def get_element_html(selector: str) -> str: """Get the outer HTML of a specific element.""" return _get_sb().get_element_html(selector) @mcp.tool() +@handle_sb_errors def get_element_attribute(selector: str, attribute: str) -> Any: """Get one attribute's value from an element.""" return _get_sb().get_element_attribute(selector, attribute) @mcp.tool() -def get_element_attributes(selector: str) -> dict: +@handle_sb_errors +def get_element_attributes(selector: str) -> dict | str: """Get all attributes of an element as a dict.""" return _get_sb().get_element_attributes(selector) @mcp.tool() -def find_elements_count(selector: str, timeout: int | None = None) -> int: - """Count how many elements on the page match a selector.""" +@handle_sb_errors +def find_elements_count( + selector: str, timeout: int | None = None +) -> int | str: + """Get the count of how many elements on the page match the selector.""" return len(_get_sb().find_elements(selector, timeout=timeout)) @mcp.tool() -def is_element_present(selector: str) -> bool: - """Check whether an element matching a selector exists in the DOM.""" +@handle_sb_errors +def is_element_present(selector: str) -> bool | str: + """Return whether an element matching the selector exists in the DOM.""" return _get_sb().is_element_present(selector) @mcp.tool() -def is_element_visible(selector: str) -> bool: - """Check whether an element matching a selector is visible.""" +@handle_sb_errors +def is_element_visible(selector: str) -> bool | str: + """Return whether an element matching the selector is visible.""" return _get_sb().is_element_visible(selector) @mcp.tool() -def is_text_visible(text: str, selector: str = "body") -> bool: - """Check whether specific text is visible within an element.""" +@handle_sb_errors +def is_text_visible(text: str, selector: str = "body") -> bool | str: + """Return whether the specific text is visible within an element.""" return _get_sb().is_text_visible(text, selector) @mcp.tool() -def get_all_urls(absolute: bool = True) -> list[str]: +@handle_sb_errors +def get_all_urls(absolute: bool = True) -> list[str] | str: """Get all linked URLs (a, link, img, script, meta) on the page.""" return _get_sb().get_all_urls(absolute=absolute) @@ -243,23 +303,30 @@ def get_all_urls(absolute: bool = True) -> list[str]: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def click( selector: str, timeout: int | None = None, scroll: bool = True ) -> str: """Click an element matched by a CSS selector (or by text, e.g. - 'a:contains("Sign in")').""" + 'a:contains("Sign in")'). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().click(selector, timeout=timeout, scroll=scroll) return f"Clicked {selector}" @mcp.tool() +@handle_sb_errors def click_if_visible(selector: str, timeout: int = 0) -> str: - """Click an element only if it's currently visible; no-op otherwise.""" + """Click an element only if it's currently visible; no-op otherwise. + If a `timeout` is given, then waits up to that long for the element + to appear first before performing the click.""" _get_sb().click_if_visible(selector, timeout=timeout) return f"click_if_visible ran for {selector}" @mcp.tool() +@handle_sb_errors def click_visible_elements(selector: str, limit: int = 0) -> str: """Click every currently-visible element matching a selector, in order (e.g. checking every checkbox on a page). limit=0 means no limit.""" @@ -268,6 +335,7 @@ def click_visible_elements(selector: str, limit: int = 0) -> str: @mcp.tool() +@handle_sb_errors def click_nth_element(selector: str, number: int) -> str: """Click the Nth element (1-indexed) matching a selector.""" _get_sb().click_nth_element(selector, number) @@ -275,6 +343,7 @@ def click_nth_element(selector: str, number: int) -> str: @mcp.tool() +@handle_sb_errors def click_link(link_text: str) -> str: """Click a link ( tag) by its visible text.""" _get_sb().click_link(link_text) @@ -282,34 +351,47 @@ def click_link(link_text: str) -> str: @mcp.tool() +@handle_sb_errors def type_text(selector: str, text: str, timeout: int | None = None) -> str: - """Clear a field and type text into it.""" + """Clear a field and type text into it. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().type(selector, text, timeout=timeout) return f"Typed into {selector}" @mcp.tool() +@handle_sb_errors def send_keys(selector: str, text: str, timeout: int | None = None) -> str: - """Send keystrokes to an element without clearing it first.""" + """Send keystrokes to an element without clearing it first. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().send_keys(selector, text, timeout=timeout) return f"Sent keys to {selector}" @mcp.tool() +@handle_sb_errors def set_value(selector: str, text: str, timeout: int | None = None) -> str: - """Set an input's value directly (e.g. for sliders, fast form fills).""" + """Set an input's value directly (e.g. for sliders, fast form fills). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().set_value(selector, text, timeout=timeout) return f"Set value of {selector}" @mcp.tool() +@handle_sb_errors def clear_input(selector: str, timeout: int | None = None) -> str: - """Clear an input field.""" + """Clear an input field. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout. """ _get_sb().clear_input(selector, timeout=timeout) return f"Cleared {selector}" @mcp.tool() +@handle_sb_errors def submit(selector: str) -> str: """Submit a form via a selector inside it.""" _get_sb().submit(selector) @@ -317,45 +399,63 @@ def submit(selector: str) -> str: @mcp.tool() -def select_option_by_text(dropdown_selector: str, option_text: str) -> str: - """Select a dropdown option by its visible text. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_text(dropdown_selector, option) + return f"Selected text '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_value(dropdown_selector: str, value: str) -> str: - """Select a dropdown option by its value attribute. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_value(dropdown_selector, option) + return f"Selected value '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_index(dropdown_selector: str, index: int) -> str: - """Select a dropdown option by its 0-based index. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_index(dropdown_selector, option) + return f"Selected index {option} in {dropdown_selector}" @mcp.tool() +@handle_sb_errors def focus(selector: str) -> str: - """Move focus to an element.""" + """Move focus to an element. + Raises an exception if the element isn't found within the default timeout. + """ el = _get_sb().find_element(selector) el.focus() return f"Focused {selector}" @mcp.tool() +@handle_sb_errors def highlight(selector: str) -> str: """Briefly highlight an element (useful when narrating actions on - a visible/headed browser).""" + a visible/headed browser). + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().highlight(selector) return f"Highlighted {selector}" @mcp.tool() +@handle_sb_errors def nested_click(parent_selector: str, selector: str) -> str: - """Click an element nested inside another (e.g. inside an iframe).""" + """Click an element nested inside another (e.g. inside an iframe). + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().nested_click(parent_selector, selector) return f"Clicked {selector} inside {parent_selector}" @@ -365,40 +465,55 @@ def nested_click(parent_selector: str, selector: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() -def wait_for_element(selector: str, timeout: int | None = None) -> str: - """Wait until an element is present in the DOM.""" - _get_sb().wait_for_element(selector, timeout=timeout) +@handle_sb_errors +def wait_for_element_present(selector: str, timeout: int | None = None) -> str: + """Wait until the element is present in the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" + _get_sb().wait_for_element_present(selector, timeout=timeout) return f"Element {selector} is present." @mcp.tool() +@handle_sb_errors def wait_for_element_visible(selector: str, timeout: int | None = None) -> str: - """Wait until an element is visible.""" + """Wait until the element is visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't visible within the timeout.""" _get_sb().wait_for_element_visible(selector, timeout=timeout) return f"Element {selector} is visible." @mcp.tool() +@handle_sb_errors def wait_for_element_not_visible( selector: str, timeout: int | None = None ) -> str: - """Wait until an element is no longer visible.""" + """Wait until an element is no longer visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element is still visible after the timeout.""" _get_sb().wait_for_element_not_visible(selector, timeout=timeout) return f"Element {selector} is no longer visible." @mcp.tool() +@handle_sb_errors def wait_for_element_absent(selector: str, timeout: int | None = None) -> str: - """Wait until an element is removed from the DOM.""" + """Wait until an element is removed from the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element is still present after the timeout.""" _get_sb().wait_for_element_absent(selector, timeout=timeout) return f"Element {selector} is now absent." @mcp.tool() +@handle_sb_errors def wait_for_text( text: str, selector: str = "body", timeout: int | None = None ) -> str: - """Wait until specific text appears within an element.""" + """Wait until the text substring appears within an element. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't visible within the timeout.""" _get_sb().wait_for_text(text, selector, timeout=timeout) return f"Text '{text}' appeared in {selector}." @@ -408,54 +523,82 @@ def wait_for_text( # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def assert_element(selector: str, timeout: int | None = None) -> str: - """Assert an element is present in the DOM.""" + """Assert that an element is present in the DOM. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().assert_element(selector, timeout=timeout) return f"Confirmed {selector} is present." @mcp.tool() +@handle_sb_errors def assert_element_visible(selector: str, timeout: int | None = None) -> str: - """Assert an element is visible.""" + """Assert that an element is visible on the page. + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found within the timeout.""" _get_sb().assert_element_visible(selector, timeout=timeout) return f"Confirmed {selector} is visible." @mcp.tool() +@handle_sb_errors def assert_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: - """Assert text is present within an element.""" + """Assert that the text substring appears within the given element + (with the matching selector) in the given timeout (seconds), + with leading and trailing whitespace automatically ignored. + If no `selector` given, then it defaults to "html" (CSS selector). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found or assertion fails.""" _get_sb().assert_text(text, selector, timeout=timeout) return f"Confirmed '{text}' is present in {selector}." @mcp.tool() +@handle_sb_errors def assert_exact_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: - """Assert an element's text matches exactly.""" + """Assert that the text matches the element's text exactly + (with leading/trailing whitespace automatically ignored) + in the given timeout (seconds). + If no `selector` given, then it defaults to "html" (CSS selector). + If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. + Raises an exception if the element isn't found or assertion fails.""" _get_sb().assert_exact_text(text, selector, timeout=timeout) return f"Confirmed {selector} text is exactly '{text}'." @mcp.tool() +@handle_sb_errors def assert_title(title: str) -> str: - """Assert the page title matches exactly.""" + """Assert that the title matches the page title exactly, + with leading and trailing whitespace ignored. + Raises an exception if the expected title doesn't + match the actual title within 7 seconds.""" _get_sb().assert_title(title) return f"Confirmed title is '{title}'." @mcp.tool() +@handle_sb_errors def assert_url(url: str) -> str: - """Assert the current URL matches exactly.""" + """Assert that the url matches the current URL exactly. + Raises an exception if the expected url doesn't + match the actual url within 7 seconds.""" _get_sb().assert_url(url) return f"Confirmed URL is '{url}'." @mcp.tool() +@handle_sb_errors def assert_url_contains(substring: str) -> str: - """Assert the current URL contains a substring.""" + """Assert that the current URL contains the given substring. + Raises an exception if the expected substring isn't + found in the actual url within 7 seconds.""" _get_sb().assert_url_contains(substring) return f"Confirmed URL contains '{substring}'." @@ -465,12 +608,14 @@ def assert_url_contains(substring: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_all_cookies() -> Any: """Get all cookies for the current session.""" return _get_sb().get_all_cookies() @mcp.tool() +@handle_sb_errors def clear_cookies() -> str: """Clear all cookies.""" _get_sb().clear_cookies() @@ -478,6 +623,7 @@ def clear_cookies() -> str: @mcp.tool() +@handle_sb_errors def save_cookies(name: str = "cookies.txt") -> str: """Save current cookies to a file.""" _get_sb().save_cookies(name=name) @@ -485,6 +631,7 @@ def save_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def load_cookies(name: str = "cookies.txt") -> str: """Load cookies from a previously saved file.""" _get_sb().load_cookies(name=name) @@ -492,12 +639,14 @@ def load_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def get_local_storage_item(key: str) -> Any: """Get a value from the page's localStorage.""" return _get_sb().get_local_storage_item(key) @mcp.tool() +@handle_sb_errors def set_local_storage_item(key: str, value: str) -> str: """Set a value in the page's localStorage.""" _get_sb().set_local_storage_item(key, value) @@ -505,12 +654,14 @@ def set_local_storage_item(key: str, value: str) -> str: @mcp.tool() +@handle_sb_errors def get_session_storage_item(key: str) -> Any: """Get a value from the page's sessionStorage.""" return _get_sb().get_session_storage_item(key) @mcp.tool() +@handle_sb_errors def set_session_storage_item(key: str, value: str) -> str: """Set a value in the page's sessionStorage.""" _get_sb().set_session_storage_item(key, value) @@ -522,6 +673,7 @@ def set_session_storage_item(key: str, value: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def scroll_into_view(selector: str) -> str: """Scroll an element into view.""" _get_sb().scroll_into_view(selector) @@ -529,6 +681,7 @@ def scroll_into_view(selector: str) -> str: @mcp.tool() +@handle_sb_errors def scroll_to_top() -> str: """Scroll to the top of the page.""" _get_sb().scroll_to_top() @@ -536,6 +689,7 @@ def scroll_to_top() -> str: @mcp.tool() +@handle_sb_errors def scroll_to_bottom() -> str: """Scroll to the bottom of the page.""" _get_sb().scroll_to_bottom() @@ -543,6 +697,7 @@ def scroll_to_bottom() -> str: @mcp.tool() +@handle_sb_errors def scroll_up(amount: int = 25) -> str: """Scroll up by a relative amount.""" _get_sb().scroll_up(amount=amount) @@ -550,6 +705,7 @@ def scroll_up(amount: int = 25) -> str: @mcp.tool() +@handle_sb_errors def scroll_down(amount: int = 25) -> str: """Scroll down by a relative amount.""" _get_sb().scroll_down(amount=amount) @@ -561,12 +717,14 @@ def scroll_down(amount: int = 25) -> str: # --------------------------------------------------------------------------- @mcp.tool() -def get_window_rect() -> dict: +@handle_sb_errors +def get_window_rect() -> dict | str: """Get the current window's position and size.""" return _get_sb().get_window_rect() @mcp.tool() +@handle_sb_errors def set_window_rect(x: int, y: int, width: int, height: int) -> str: """Set the current window's position and size.""" _get_sb().set_window_rect(x, y, width, height) @@ -574,6 +732,7 @@ def set_window_rect(x: int, y: int, width: int, height: int) -> str: @mcp.tool() +@handle_sb_errors def maximize() -> str: """Maximize the browser window.""" _get_sb().maximize() @@ -581,6 +740,7 @@ def maximize() -> str: @mcp.tool() +@handle_sb_errors def minimize() -> str: """Minimize the browser window.""" _get_sb().minimize() @@ -588,6 +748,7 @@ def minimize() -> str: @mcp.tool() +@handle_sb_errors def open_new_tab(url: str | None = None, switch_to: bool = True) -> str: """Open a new browser tab, optionally navigating and switching to it.""" _get_sb().open_new_tab(url=url, switch_to=switch_to) @@ -595,6 +756,7 @@ def open_new_tab(url: str | None = None, switch_to: bool = True) -> str: @mcp.tool() +@handle_sb_errors def switch_to_tab(tab_index: int) -> str: """Switch to a tab by its index (as returned by get_tabs).""" tabs = _get_sb().get_tabs() @@ -603,6 +765,7 @@ def switch_to_tab(tab_index: int) -> str: @mcp.tool() +@handle_sb_errors def switch_to_newest_tab() -> str: """Switch to the most recently opened tab.""" _get_sb().switch_to_newest_tab() @@ -610,6 +773,7 @@ def switch_to_newest_tab() -> str: @mcp.tool() +@handle_sb_errors def close_active_tab() -> str: """Close the currently active tab.""" _get_sb().close_active_tab() @@ -617,7 +781,8 @@ def close_active_tab() -> str: @mcp.tool() -def get_tabs_count() -> int: +@handle_sb_errors +def get_tabs_count() -> int | str: """Get how many tabs are currently open.""" return len(_get_sb().get_tabs()) @@ -627,6 +792,7 @@ def get_tabs_count() -> int: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def solve_captcha() -> str: """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" _get_sb().solve_captcha() @@ -638,6 +804,7 @@ def solve_captcha() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def save_screenshot( name: str = "screenshot.png", folder: str | None = None ) -> str: @@ -647,6 +814,7 @@ def save_screenshot( @mcp.tool() +@handle_sb_errors def save_page_source( name: str = "page_source.html", folder: str | None = None ) -> str: @@ -656,6 +824,7 @@ def save_page_source( @mcp.tool() +@handle_sb_errors def save_as_pdf(name: str = "page.pdf", folder: str | None = None) -> str: """Print the current page to a PDF file.""" _get_sb().save_as_pdf(name, folder=folder) @@ -663,13 +832,17 @@ def save_as_pdf(name: str = "page.pdf", folder: str | None = None) -> str: @mcp.tool() +@handle_sb_errors def evaluate(expression: str) -> Any: """Evaluate a JavaScript expression in the page context and return the - result. Equivalent to execute_script.""" + result. Equivalent to execute_script. This method can run any arbitrary + JavaScript on any site, so take any necessary precautions to prevent + AI harnesses from running scripts that you don't want them to run.""" return _get_sb().evaluate(expression) @mcp.tool() +@handle_sb_errors def sleep(seconds: float) -> str: """Pause execution for a number of seconds.""" _get_sb().sleep(seconds) @@ -677,6 +850,7 @@ def sleep(seconds: float) -> str: @mcp.tool() +@handle_sb_errors def get_user_agent() -> str: """Get the browser's current user agent string.""" return _get_sb().get_user_agent() diff --git a/driver_server.py b/driver_server.py index 7905fea..95b6fb2 100644 --- a/driver_server.py +++ b/driver_server.py @@ -5,12 +5,14 @@ Exposes SeleniumBase browser automation as tools callable by any MCP client (Claude Desktop, Claude Code, etc.) over stdio. -Model: one persistent browser session per server process. Call start_browser -once, drive it with the other tools, then close_browser when done. +Model: One persistent browser session per server process. +Call start_browser once; drive it with the other tools; then close_browser. """ from __future__ import annotations import atexit import sys +from functools import wraps +from typing import Any from mcp.server import MCPServer from seleniumbase import Driver @@ -25,11 +27,26 @@ def _get_driver() -> Driver: return _driver +def handle_sb_errors(func): + """Catches SeleniumBase errors and surfaces them as descriptive strings + so the LLM agent can read them and self-correct.""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_type = e.__class__.__name__ + error_msg = str(e).strip() + return f"Error in {func.__name__}: {error_type} - {error_msg}" + return wrapper + + # --------------------------------------------------------------------------- # Session lifecycle # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def start_browser( browser: str = "chrome", headless: bool = False, @@ -69,6 +86,7 @@ def start_browser( @mcp.tool() +@handle_sb_errors def close_browser() -> str: """Close the browser and end the session.""" global _driver @@ -84,40 +102,55 @@ def close_browser() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def navigate(url: str) -> str: - """Navigate to a URL.""" + """Navigate to the given URL in the web browser. + If the URL doesn't start with a protocol (eg: `https://`), + then `https://` is automatically prefixed in before navigation. + Waits until the initial HTML document is fully parsed and loaded. + New pages visited will show up in browser navigation history. + If the URL is invalid or the page can't load due to an issue, + then the corresponding errors will be raised.""" _get_driver().open(url) return f"Navigated to {url}" @mcp.tool() +@handle_sb_errors def go_back() -> str: - """Go back one page in browser history.""" + """Go back one page in browser history. + Same as clicking the Back button in the web browser.""" _get_driver().go_back() return "Navigated back." @mcp.tool() +@handle_sb_errors def go_forward() -> str: - """Go forward one page in browser history.""" + """Go forward one page in browser history. + Same as clicking the Forward button in the web browser.""" _get_driver().go_forward() return "Navigated forward." @mcp.tool() +@handle_sb_errors def refresh_page() -> str: - """Refresh the current page.""" + """Refresh the current page. + Same as clicking the Reload button in the web browser.""" _get_driver().refresh_page() return "Page refreshed." @mcp.tool() +@handle_sb_errors def get_current_url() -> str: """Get the URL of the current page.""" return _get_driver().get_current_url() @mcp.tool() +@handle_sb_errors def get_title() -> str: """Get the title of the current page.""" return _get_driver().get_title() @@ -128,24 +161,30 @@ def get_title() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_page_source() -> str: """Get the full HTML source of the current page.""" return _get_driver().get_page_source() @mcp.tool() +@handle_sb_errors def get_text(selector: str) -> str: - """Get the visible text of an element matched by a CSS selector.""" + """Get the visible text of an element matched by a CSS selector. + Raises an exception if the element isn't found within the default timeout. + """ return _get_driver().get_text(selector) @mcp.tool() +@handle_sb_errors def find_elements_count(selector: str) -> int: """Count how many elements on the page match a CSS selector.""" return len(_get_driver().find_elements(selector)) @mcp.tool() +@handle_sb_errors def is_element_visible(selector: str) -> bool: """Check whether an element matched by a CSS selector is visible.""" return _get_driver().is_element_visible(selector) @@ -156,45 +195,70 @@ def is_element_visible(selector: str) -> bool: # --------------------------------------------------------------------------- @mcp.tool() -def click(selector: str, by: str = "css") -> str: - """Click an element. - - Args: - selector: CSS selector or XPath string identifying the element. - by: "css" or "xpath". - """ +@handle_sb_errors +def click(selector: str, timeout: int = 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() - d.click(f"xpath={selector}" if by == "xpath" else selector) + d.click(selector, timeout=timeout) return f"Clicked {selector}" @mcp.tool() -def type_text(selector: str, text: str, clear_first: bool = True) -> str: - """Type text into an input field. - +@handle_sb_errors +def type_text( + selector: str, text: str, clear_first: bool = True, timeout: int = 7 +) -> str: + """Type text into an input field / textarea. + Raises an exception if the element isn't found within the timeout. Args: - selector: CSS selector for the field. - text: Text to type. + selector: The selector for the field. + text: The text to type. clear_first: Clear the field's existing contents before typing. - """ + timeout: The maximum time to wait for an element in seconds.""" d = _get_driver() if clear_first: - d.type(selector, text) + d.type(selector, text, timeout=timeout) else: - d.add_text(selector, text) + d.add_text(selector, text, timeout=timeout) return f"Typed into {selector}" @mcp.tool() -def select_option(selector: str, option_text: str) -> str: - """Select a dropdown ( dropdown option by its visible text. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_driver().select_option_by_text(dropdown_selector, option) + return f"Selected text '{option}' in {dropdown_selector}" @mcp.tool() +@handle_sb_errors +def select_option_by_value(dropdown_selector: str, option: str) -> str: + """Select a dropdown option by its 0-based index. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_driver().select_option_by_index(dropdown_selector, option) + return f"Selected index '{option}' in {dropdown_selector}" + + +@mcp.tool() +@handle_sb_errors def wait_for_element(selector: str, timeout: int = 10) -> str: - """Wait until an element matched by a CSS selector appears.""" + """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) return f"Element {selector} appeared." @@ -204,6 +268,7 @@ def wait_for_element(selector: str, timeout: int = 10) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def switch_to_frame(selector: str) -> str: """Switch driver focus into an iframe matched by a CSS selector.""" _get_driver().switch_to_frame(selector) @@ -211,6 +276,7 @@ def switch_to_frame(selector: str) -> str: @mcp.tool() +@handle_sb_errors def switch_to_default_content() -> str: """Switch driver focus back out to the main page (out of any iframe).""" _get_driver().switch_to_default_content() @@ -222,11 +288,10 @@ def switch_to_default_content() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def assert_text(text: str, selector: str | None = None) -> str: """Assert that text is present on the page, or within a specific element. - - Raises an error (returned as a tool error to the client) if not found. - """ + Raises an error (returned as a tool error to the client) if not found.""" d = _get_driver() if selector: d.assert_text(text, selector) @@ -240,15 +305,17 @@ def assert_text(text: str, selector: str | None = None) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def activate_cdp_mode(url: str | None = None) -> str: - """Switch the current session into Pure CDP Mode, optionally navigating - to a URL. Once active, CDP-only capabilities (e.g. more thorough - stealth) apply to subsequent actions. Requires uc=True.""" + """Switch the current browser session into CDP Mode, which adds stealth + capabilities and additional methods that use the Chrome DevTools Protocol. + You can optionally specify a URL to navigate to. Requires uc=True.""" _get_driver().activate_cdp_mode(url) return f"CDP Mode activated (url={url!r})" @mcp.tool() +@handle_sb_errors def solve_captcha() -> str: """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" _get_driver().solve_captcha() @@ -260,6 +327,7 @@ def solve_captcha() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def screenshot(filename: str = "screenshot.png") -> str: """Take a screenshot of the current page and save it to disk.""" _get_driver().save_screenshot(filename) @@ -267,8 +335,12 @@ def screenshot(filename: str = "screenshot.png") -> str: @mcp.tool() -def execute_script(script: str): - """Execute JavaScript in the page context and return the result.""" +@handle_sb_errors +def execute_script(script: str) -> Any: + """Execute JavaScript in the page context and return the result. + This method can run any arbitrary JavaScript on any site, + so take any necessary precautions to prevent AI harnesses + from running scripts that you don't want them to run.""" return _get_driver().execute_script(script) diff --git a/sb_server.py b/sb_server.py index 6703462..591a26d 100644 --- a/sb_server.py +++ b/sb_server.py @@ -19,12 +19,13 @@ Reference: github.com/seleniumbase/SeleniumBase/blob/master/help_docs/method_summary.md -Model: one persistent SB() session per server process. Call start_browser -once, drive it with the other tools, then close_browser. +Model: One persistent SB() session per server process. +Call start_browser once; drive it with the other tools; then close_browser. """ from __future__ import annotations import atexit import sys +from functools import wraps from typing import Any from mcp.server import MCPServer from seleniumbase import SB @@ -41,11 +42,26 @@ def _get_sb() -> Any: return _sb +def handle_sb_errors(func): + """Catches SeleniumBase errors and surfaces them as descriptive strings + so the LLM agent can read them and self-correct.""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_type = e.__class__.__name__ + error_msg = str(e).strip() + return f"Error in {func.__name__}: {error_type} - {error_msg}" + return wrapper + + # --------------------------------------------------------------------------- # Session lifecycle # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def start_browser( browser: str = "chrome", headless: bool = False, @@ -56,7 +72,6 @@ def start_browser( ad_block: bool = False, ) -> 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. @@ -93,6 +108,7 @@ def start_browser( @mcp.tool() +@handle_sb_errors def close_browser() -> str: """Close the browser and end the session.""" global _sb_context, _sb @@ -109,52 +125,69 @@ def close_browser() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def navigate(url: str) -> str: - """Navigate to a URL.""" + """Navigate to the given URL in the web browser. + If the URL doesn't start with a protocol (eg: `https://`), + then `https://` is automatically prefixed in before navigation. + Waits until the initial HTML document is fully parsed and loaded. + New pages visited will show up in browser navigation history. + If the URL is invalid or the page can't load due to an issue, + then the corresponding errors will be raised.""" _get_sb().goto(url) return f"Navigated to {url}" @mcp.tool() +@handle_sb_errors def refresh_page() -> str: - """Refresh the current page.""" + """Refresh the current page. + Same as clicking the Reload button in the web browser.""" _get_sb().refresh() return "Page refreshed." @mcp.tool() +@handle_sb_errors def go_back() -> str: - """Go back one page in browser history.""" + """Go back one page in browser history. + Same as clicking the Back button in the web browser.""" _get_sb().go_back() return "Navigated back." @mcp.tool() +@handle_sb_errors def go_forward() -> str: - """Go forward one page in browser history.""" + """Go forward one page in browser history. + Same as clicking the Forward button in the web browser.""" _get_sb().go_forward() return "Navigated forward." @mcp.tool() +@handle_sb_errors def get_current_url() -> str: """Get the URL of the current page.""" return _get_sb().get_current_url() @mcp.tool() +@handle_sb_errors def get_title() -> str: """Get the title of the current page.""" return _get_sb().get_title() @mcp.tool() +@handle_sb_errors def get_origin() -> str: """Get the origin (scheme + host) of the current page.""" return _get_sb().get_origin() @mcp.tool() +@handle_sb_errors def get_user_agent() -> str: """Get the browser's current user agent string.""" return _get_sb().get_user_agent() @@ -165,61 +198,73 @@ def get_user_agent() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_text(selector: str = "body") -> str: - """Get the visible text within an element (default: whole page body).""" + """Get the visible text within an element (default: whole page body). + Raises an exception if the element isn't found within the default timeout. + """ return _get_sb().get_text(selector) @mcp.tool() +@handle_sb_errors def get_html_source() -> str: """Get the full HTML source of the current page.""" return _get_sb().get_page_source() @mcp.tool() +@handle_sb_errors def get_element_html(selector: str) -> str: """Get the outer HTML of a specific element.""" return _get_sb().get_html(selector) @mcp.tool() +@handle_sb_errors def get_attribute(selector: str, attribute: str) -> Any: """Get one attribute's value from an element.""" return _get_sb().get_attribute(selector, attribute) @mcp.tool() -def find_elements_count(selector: str) -> int: +@handle_sb_errors +def find_elements_count(selector: str) -> int | str: """Count how many elements on the page match a selector.""" return len(_get_sb().find_elements(selector)) @mcp.tool() -def is_element_present(selector: str) -> bool: +@handle_sb_errors +def is_element_present(selector: str) -> bool | str: """Check whether an element matching a selector exists in the DOM.""" return _get_sb().is_element_present(selector) @mcp.tool() -def is_element_visible(selector: str) -> bool: +@handle_sb_errors +def is_element_visible(selector: str) -> bool | str: """Check whether an element matching a selector is visible.""" return _get_sb().is_element_visible(selector) @mcp.tool() -def is_element_clickable(selector: str) -> bool: +@handle_sb_errors +def is_element_clickable(selector: str) -> bool | str: """Check whether an element matching a selector is clickable.""" return _get_sb().is_element_clickable(selector) @mcp.tool() -def is_text_visible(text: str, selector: str = "html") -> bool: +@handle_sb_errors +def is_text_visible(text: str, selector: str = "html") -> bool | str: """Check whether specific text is visible within an element.""" return _get_sb().is_text_visible(text, selector) @mcp.tool() -def is_selected(selector: str) -> bool: +@handle_sb_errors +def is_selected(selector: str) -> bool | str: """Check whether a checkbox/radio-button element is selected/checked.""" return _get_sb().is_selected(selector) @@ -229,13 +274,16 @@ def is_selected(selector: str) -> bool: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def click(selector: str, timeout: int | None = None) -> str: - """Click an element matched by a CSS selector.""" + """Click an element matched by the given CSS selector. + Raises an exception if the element isn't found within the timeout.""" _get_sb().click(selector, timeout=timeout) return f"Clicked {selector}" @mcp.tool() +@handle_sb_errors def click_if_visible(selector: str) -> str: """Click an element only if it's currently visible; no-op otherwise.""" _get_sb().click_if_visible(selector) @@ -243,6 +291,7 @@ def click_if_visible(selector: str) -> str: @mcp.tool() +@handle_sb_errors def click_visible_elements(selector: str, limit: int = 0) -> str: """Click every currently-visible element matching a selector, in order. limit=0 means no limit.""" @@ -251,90 +300,127 @@ def click_visible_elements(selector: str, limit: int = 0) -> str: @mcp.tool() +@handle_sb_errors def click_nth_visible_element(selector: str, number: int) -> str: - """Click the Nth visible element (1-indexed) matching a selector.""" + """Click the Nth visible element (1-indexed) matching a selector. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().click_nth_visible_element(selector, number) return f"Clicked visible element #{number} matching {selector}" @mcp.tool() +@handle_sb_errors def click_link(link_text: str) -> str: - """Click a link ( tag) by its visible text.""" + """Click a link ( tag) by its visible text. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().click_link(link_text) return f"Clicked link with text '{link_text}'" @mcp.tool() +@handle_sb_errors def double_click(selector: str) -> str: - """Double-click an element.""" + """Double-click an element. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().double_click(selector) return f"Double-clicked {selector}" @mcp.tool() +@handle_sb_errors def context_click(selector: str) -> str: - """Right-click (context-click) an element.""" + """Right-click (context-click) an element. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().context_click(selector) return f"Right-clicked {selector}" @mcp.tool() +@handle_sb_errors def type_text(selector: str, text: str) -> str: - """Clear a field and type text into it.""" + """Clear a field and type text into it. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().type(selector, text) return f"Typed into {selector}" @mcp.tool() +@handle_sb_errors def send_keys(selector: str, text: str) -> str: - """Send keystrokes to an element without clearing it first.""" + """Send keystrokes to an element without clearing it first. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().send_keys(selector, text) return f"Sent keys to {selector}" @mcp.tool() +@handle_sb_errors def set_value(selector: str, text: str) -> str: - """Set an input's value directly (e.g. for sliders, fast form fills).""" + """Set an input's value directly (e.g. for sliders, fast form fills). + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().set_value(selector, text) return f"Set value of {selector}" @mcp.tool() +@handle_sb_errors def clear_input(selector: str) -> str: - """Clear an input field.""" + """Clear an input field. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().clear(selector) return f"Cleared {selector}" @mcp.tool() +@handle_sb_errors def submit(selector: str) -> str: - """Submit a form via a selector inside it.""" + """Submit a form via a selector inside it. + Raises an exception if the element isn't found within the default timeout. + """ _get_sb().submit(selector) return f"Submitted form via {selector}" @mcp.tool() -def select_option_by_text(dropdown_selector: str, option_text: str) -> str: - """Select a dropdown option by its visible text. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_text(dropdown_selector, option) + return f"Selected text '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_value(dropdown_selector: str, value: str) -> str: - """Select a dropdown option by its value attribute. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_value(dropdown_selector, option) + return f"Selected value '{option}' in {dropdown_selector}" @mcp.tool() -def select_option_by_index(dropdown_selector: str, index: int) -> str: - """Select a dropdown option by its 0-based index. + Raises an exception if the element or option aren't found + within the default timeout, which is 7 seconds.""" + _get_sb().select_option_by_index(dropdown_selector, option) + return f"Selected index {option} in {dropdown_selector}" @mcp.tool() +@handle_sb_errors def hover_and_click(hover_selector: str, click_selector: str) -> str: """Hover over one element (e.g. to open a dropdown), then click another.""" _get_sb().hover_and_click(hover_selector, click_selector) @@ -342,6 +428,7 @@ def hover_and_click(hover_selector: str, click_selector: str) -> str: @mcp.tool() +@handle_sb_errors def drag_and_drop(drag_selector: str, drop_selector: str) -> str: """Drag one element onto another.""" _get_sb().drag_and_drop(drag_selector, drop_selector) @@ -349,6 +436,7 @@ def drag_and_drop(drag_selector: str, drop_selector: str) -> str: @mcp.tool() +@handle_sb_errors def nested_click(parent_selector: str, selector: str) -> str: """Click an element nested inside another (e.g. inside an iframe).""" _get_sb().nested_click(parent_selector, selector) @@ -356,6 +444,7 @@ def nested_click(parent_selector: str, selector: str) -> str: @mcp.tool() +@handle_sb_errors def choose_file(selector: str, file_path: str) -> str: """Set a element to upload a local file.""" _get_sb().choose_file(selector, file_path) @@ -367,6 +456,7 @@ def choose_file(selector: str, file_path: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def wait_for_element(selector: str, timeout: int | None = None) -> str: """Wait until an element is visible on the page.""" _get_sb().wait_for_element(selector, timeout=timeout) @@ -374,6 +464,7 @@ def wait_for_element(selector: str, timeout: int | None = None) -> str: @mcp.tool() +@handle_sb_errors def wait_for_element_present(selector: str, timeout: int | None = None) -> str: """Wait until an element is present in the DOM (may not be visible).""" _get_sb().wait_for_element_present(selector, timeout=timeout) @@ -381,6 +472,7 @@ def wait_for_element_present(selector: str, timeout: int | None = None) -> str: @mcp.tool() +@handle_sb_errors def wait_for_element_not_visible( selector: str, timeout: int | None = None ) -> str: @@ -390,6 +482,7 @@ def wait_for_element_not_visible( @mcp.tool() +@handle_sb_errors def wait_for_element_absent(selector: str, timeout: int | None = None) -> str: """Wait until an element is removed from the DOM.""" _get_sb().wait_for_element_absent(selector, timeout=timeout) @@ -397,6 +490,7 @@ def wait_for_element_absent(selector: str, timeout: int | None = None) -> str: @mcp.tool() +@handle_sb_errors def wait_for_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: @@ -410,6 +504,7 @@ def wait_for_text( # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def assert_element(selector: str, timeout: int | None = None) -> str: """Assert an element is visible.""" _get_sb().assert_element(selector, timeout=timeout) @@ -417,6 +512,7 @@ def assert_element(selector: str, timeout: int | None = None) -> str: @mcp.tool() +@handle_sb_errors def assert_element_present(selector: str, timeout: int | None = None) -> str: """Assert an element is present in the DOM (may not be visible).""" _get_sb().assert_element_present(selector, timeout=timeout) @@ -424,6 +520,7 @@ def assert_element_present(selector: str, timeout: int | None = None) -> str: @mcp.tool() +@handle_sb_errors def assert_element_not_visible( selector: str, timeout: int | None = None ) -> str: @@ -433,6 +530,7 @@ def assert_element_not_visible( @mcp.tool() +@handle_sb_errors def assert_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: @@ -442,6 +540,7 @@ def assert_text( @mcp.tool() +@handle_sb_errors def assert_exact_text( text: str, selector: str = "html", timeout: int | None = None ) -> str: @@ -451,6 +550,7 @@ def assert_exact_text( @mcp.tool() +@handle_sb_errors def assert_title(title: str) -> str: """Assert the page title matches exactly.""" _get_sb().assert_title(title) @@ -458,6 +558,7 @@ def assert_title(title: str) -> str: @mcp.tool() +@handle_sb_errors def assert_url(url: str) -> str: """Assert the current URL matches exactly.""" _get_sb().assert_url(url) @@ -465,6 +566,7 @@ def assert_url(url: str) -> str: @mcp.tool() +@handle_sb_errors def assert_url_contains(substring: str) -> str: """Assert the current URL contains a substring.""" _get_sb().assert_url_contains(substring) @@ -472,6 +574,7 @@ def assert_url_contains(substring: str) -> str: @mcp.tool() +@handle_sb_errors def assert_no_404_errors() -> str: """Assert that none of the page's links return a 404 status.""" _get_sb().assert_no_404_errors() @@ -479,6 +582,7 @@ def assert_no_404_errors() -> str: @mcp.tool() +@handle_sb_errors def assert_no_js_errors() -> str: """Assert the browser console has no JavaScript errors.""" _get_sb().assert_no_js_errors() @@ -490,12 +594,14 @@ def assert_no_js_errors() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_cookies() -> Any: """Get all cookies for the current session.""" return _get_sb().get_cookies() @mcp.tool() +@handle_sb_errors def delete_all_cookies() -> str: """Delete all cookies.""" _get_sb().delete_all_cookies() @@ -503,6 +609,7 @@ def delete_all_cookies() -> str: @mcp.tool() +@handle_sb_errors def save_cookies(name: str = "cookies.txt") -> str: """Save current cookies to a file.""" _get_sb().save_cookies(name=name) @@ -510,6 +617,7 @@ def save_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def load_cookies(name: str = "cookies.txt") -> str: """Load cookies from a previously saved file.""" _get_sb().load_cookies(name=name) @@ -517,12 +625,14 @@ def load_cookies(name: str = "cookies.txt") -> str: @mcp.tool() +@handle_sb_errors def get_local_storage_item(key: str) -> Any: """Get a value from the page's localStorage.""" return _get_sb().get_local_storage_item(key) @mcp.tool() +@handle_sb_errors def set_local_storage_item(key: str, value: str) -> str: """Set a value in the page's localStorage.""" _get_sb().set_local_storage_item(key, value) @@ -530,12 +640,14 @@ def set_local_storage_item(key: str, value: str) -> str: @mcp.tool() +@handle_sb_errors def get_session_storage_item(key: str) -> Any: """Get a value from the page's sessionStorage.""" return _get_sb().get_session_storage_item(key) @mcp.tool() +@handle_sb_errors def set_session_storage_item(key: str, value: str) -> str: """Set a value in the page's sessionStorage.""" _get_sb().set_session_storage_item(key, value) @@ -547,6 +659,7 @@ def set_session_storage_item(key: str, value: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def scroll_into_view(selector: str) -> str: """Scroll an element into view.""" _get_sb().scroll_into_view(selector) @@ -554,6 +667,7 @@ def scroll_into_view(selector: str) -> str: @mcp.tool() +@handle_sb_errors def scroll_to_top() -> str: """Scroll to the top of the page.""" _get_sb().scroll_to_top() @@ -561,6 +675,7 @@ def scroll_to_top() -> str: @mcp.tool() +@handle_sb_errors def scroll_to_bottom() -> str: """Scroll to the bottom of the page.""" _get_sb().scroll_to_bottom() @@ -568,6 +683,7 @@ def scroll_to_bottom() -> str: @mcp.tool() +@handle_sb_errors def scroll_up(amount: int = 25) -> str: """Scroll up by a relative amount.""" _get_sb().scroll_up(amount=amount) @@ -575,6 +691,7 @@ def scroll_up(amount: int = 25) -> str: @mcp.tool() +@handle_sb_errors def scroll_down(amount: int = 25) -> str: """Scroll down by a relative amount.""" _get_sb().scroll_down(amount=amount) @@ -586,12 +703,14 @@ def scroll_down(amount: int = 25) -> str: # --------------------------------------------------------------------------- @mcp.tool() -def get_window_rect() -> dict: +@handle_sb_errors +def get_window_rect() -> dict | str: """Get the current window's position and size.""" return _get_sb().get_window_rect() @mcp.tool() +@handle_sb_errors def maximize_window() -> str: """Maximize the browser window.""" _get_sb().maximize_window() @@ -599,6 +718,7 @@ def maximize_window() -> str: @mcp.tool() +@handle_sb_errors def minimize_window() -> str: """Minimize the browser window.""" _get_sb().minimize_window() @@ -606,6 +726,7 @@ def minimize_window() -> str: @mcp.tool() +@handle_sb_errors def open_new_tab(switch_to: bool = True) -> str: """Open a new browser tab, optionally switching to it.""" _get_sb().open_new_tab(switch_to=switch_to) @@ -613,6 +734,7 @@ def open_new_tab(switch_to: bool = True) -> str: @mcp.tool() +@handle_sb_errors def switch_to_newest_tab() -> str: """Switch to the most recently opened tab.""" _get_sb().switch_to_newest_tab() @@ -620,6 +742,7 @@ def switch_to_newest_tab() -> str: @mcp.tool() +@handle_sb_errors def switch_to_default_window() -> str: """Switch back to the first/original browser tab.""" _get_sb().switch_to_default_window() @@ -627,6 +750,7 @@ def switch_to_default_window() -> str: @mcp.tool() +@handle_sb_errors def switch_to_frame(selector: str = "iframe") -> str: """Switch driver focus into an iframe matched by a CSS selector.""" _get_sb().switch_to_frame(selector) @@ -634,6 +758,7 @@ def switch_to_frame(selector: str = "iframe") -> str: @mcp.tool() +@handle_sb_errors def switch_to_default_content() -> str: """Switch driver focus back out to the main page (out of any iframe).""" _get_sb().switch_to_default_content() @@ -645,15 +770,17 @@ def switch_to_default_content() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def activate_cdp_mode(url: str | None = None) -> str: - """Switch the current session into Pure CDP Mode, optionally navigating - to a URL. Once active, CDP-only capabilities (e.g. more thorough - stealth) apply to subsequent actions. Requires uc=True.""" + """Switch the current browser session into CDP Mode, which adds stealth + capabilities and additional methods that use the Chrome DevTools Protocol. + You can optionally specify a URL to navigate to. Requires uc=True.""" _get_sb().activate_cdp_mode(url) return f"CDP Mode activated (url={url!r})" @mcp.tool() +@handle_sb_errors def solve_captcha() -> str: """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" _get_sb().solve_captcha() @@ -665,6 +792,7 @@ def solve_captcha() -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def get_mfa_code(totp_key: str) -> str: """Generate a current TOTP (e.g. Google Authenticator) code from a base32 secret key.""" @@ -672,6 +800,7 @@ def get_mfa_code(totp_key: str) -> str: @mcp.tool() +@handle_sb_errors def enter_mfa_code(selector: str, totp_key: str) -> str: """Generate a current TOTP code and type it into a field.""" _get_sb().enter_mfa_code(selector, totp_key) @@ -683,6 +812,7 @@ def enter_mfa_code(selector: str, totp_key: str) -> str: # --------------------------------------------------------------------------- @mcp.tool() +@handle_sb_errors def save_screenshot( name: str = "screenshot.png", folder: str | None = None ) -> str: @@ -692,6 +822,7 @@ def save_screenshot( @mcp.tool() +@handle_sb_errors def save_page_source( name: str = "page_source.html", folder: str | None = None ) -> str: @@ -701,6 +832,7 @@ def save_page_source( @mcp.tool() +@handle_sb_errors def print_to_pdf(name: str = "page.pdf", folder: str | None = None) -> str: """Print the current page to a PDF file.""" _get_sb().print_to_pdf(name, folder=folder) @@ -708,6 +840,7 @@ def print_to_pdf(name: str = "page.pdf", folder: str | None = None) -> str: @mcp.tool() +@handle_sb_errors def download_file(file_url: str, destination_folder: str | None = None) -> str: """Download a file from a URL to a local folder.""" _get_sb().download_file(file_url, destination_folder=destination_folder) @@ -715,6 +848,7 @@ def download_file(file_url: str, destination_folder: str | None = None) -> str: @mcp.tool() +@handle_sb_errors def evaluate(expression: str) -> Any: """Evaluate a JavaScript expression in the page context and return the result.""" @@ -722,12 +856,17 @@ def evaluate(expression: str) -> Any: @mcp.tool() +@handle_sb_errors def execute_script(script: str) -> Any: - """Execute JavaScript in the page context and return the result.""" + """Execute JavaScript in the page context and return the result. + This method can run any arbitrary JavaScript on any site, + so take any necessary precautions to prevent AI harnesses + from running scripts that you don't want them to run.""" return _get_sb().execute_script(script) @mcp.tool() +@handle_sb_errors def highlight(selector: str, loops: int = 4) -> str: """Briefly highlight an element with a colored animation — useful for narrating actions on a visible/headed browser.""" @@ -736,6 +875,7 @@ def highlight(selector: str, loops: int = 4) -> str: @mcp.tool() +@handle_sb_errors def flash(selector: str, duration: float = 1) -> str: """Flash an element to draw attention to it.""" _get_sb().flash(selector, duration=duration) @@ -743,6 +883,7 @@ def flash(selector: str, duration: float = 1) -> str: @mcp.tool() +@handle_sb_errors def sleep(seconds: float) -> str: """Pause execution for a number of seconds.""" _get_sb().sleep(seconds) From c46d128b1029bc4d5d091c9e11bf6f2c7d9a5ae6 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Sun, 30 Aug 2026 00:55:37 -0400 Subject: [PATCH 2/4] Refresh Python dependencies --- pyproject.toml | 7 ++++--- requirements.txt | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 52fdfd4..b761dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,8 @@ classifiers = [ authors = [{ name = "Michael Mintz", email = "mdmintz@gmail.com" }] maintainers = [{ name = "Michael Mintz" }] dependencies = [ - "seleniumbase[mcp]>=4.52.3", + "seleniumbase[mcp]>=4.53.0", + "mcp[cli]>=2.1.1,<3.0.0", ] [project.optional-dependencies] @@ -57,12 +58,12 @@ deploy = [ "twine>=7.0.0", ] uv = [ - "uv>=0.12.5", + "uv>=0.12.7", ] [dependency-groups] # Used by `uv sync` dev = [ - "uv>=0.12.5", # Needed for `mcp dev` + "uv>=0.12.7", # Needed for `mcp dev` ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 9e36576..a563725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -seleniumbase[mcp]>=4.52.3 +seleniumbase[mcp]>=4.53.0 +mcp[cli]>=2.1.1,<3.0.0 From e1a0435ed9d620fa94016f66efefd29f35727491 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Sun, 30 Aug 2026 00:56:10 -0400 Subject: [PATCH 3/4] Update the ReadMe --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6413a5d..4dd1403 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cd seleniumbase-mcp uv sync ``` -`uv sync` reads `pyproject.toml`, creates a `.venv/` in this folder, and installs the two dependencies (`mcp[cli]`, `seleniumbase`) along with this project itself, which registers three console-script commands via `[project.scripts]`: +`uv sync` reads `pyproject.toml`, creates a `.venv/` in this folder, and installs the `seleniumbase[mcp]` dependency along with this project itself, which registers three console-script commands via `[project.scripts]`: - `seleniumbase-cdp` - `seleniumbase-driver` @@ -152,10 +152,10 @@ claude mcp add seleniumbase-sb -- uv run seleniumbase-sb | `get_text(selector)` | Visible text of an element | | `find_elements_count(selector)` | Count matches | | `is_element_visible(selector)` | Visibility check | -| `click(selector, by)` | Click (CSS or XPath) | -| `type_text(selector, text, clear_first)` | Fill a field | -| `select_option(selector, option_text)` | Choose a dropdown option | -| `wait_for_element(selector, timeout)` | Explicit wait | +| `click(selector, timeout)` | Click (CSS or XPath) | +| `type_text(selector, text, clear_first, timeout)` | Fill a field | +| `select_option_by_text(dropdown_selector, option)` | Choose a dropdown option | +| `wait_for_element_present(selector, timeout)` | Explicit wait | | `switch_to_frame(selector)` / `switch_to_default_content()` | iframe handling | | `assert_text(text, selector)` | Verify text is present | | `screenshot(filename)` | Save a screenshot | @@ -202,7 +202,7 @@ in the loop at all. Reference: | 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`, `wait_for_element_visible/not_visible/absent`, `wait_for_text` | +| 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` | From c4c2ad42b8096300c7280e0ce247c5807ad37fcf Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Sun, 30 Aug 2026 00:56:32 -0400 Subject: [PATCH 4/4] Version 1.1.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b761dea..5e62823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "seleniumbase-mcp" -version = "1.0.1" +version = "1.1.0" description = "MCP servers exposing SeleniumBase as tools for MCP clients." readme = "README.md" requires-python = ">=3.10"