diff --git a/cdp_server.py b/cdp_server.py index 26713ec..930bfa2 100644 --- a/cdp_server.py +++ b/cdp_server.py @@ -13,15 +13,26 @@ Model: One persistent `sb_cdp.Chrome` session per server process. Call start_browser once; drive it with the other tools; then close_browser. -Design notes (v2): -Tools are grouped around one CSS-selector-or-text-matched-by convention: -`selector` args accept a CSS selector, or visible text (e.g. -'a:contains("Sign in")'). Where the original tool set had several -near-identical tools for one concept (e.g. five click variants, five wait -variants, eight cookie/storage variants), those are now a single tool with -a mode/action/state/check parameter, to reduce the number of near-neighbor -tools an agent has to disambiguate between while keeping every underlying -capability available. +Design notes: +Tools follow a consistent CSS-selector-or-text matching convention: +selector arguments accept a CSS selector or visible text (for example, +a:contains("Sign in")). Related SeleniumBase capabilities are consolidated +into parameterized tools using action, mode, state, or check parameters. +This keeps the toolset compact and predictable while giving an MCP client +access to the underlying browser-automation capabilities without +having to choose between multiple near-identical tools. + +Tool-selection philosophy: +- Use get_page_info for browser/page metadata such as URL, title, origin, + and navigation history. +- Use get_content for reading visible text or HTML. +- Use find_elements for discovering and inspecting multiple matching + elements as structured data. +- Use check_state for an immediate, non-waiting state check. +- 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 @@ -44,14 +55,20 @@ def _get_sb() -> sb_cdp.CDPMethods: + """Return the active browser session or raise a useful lifecycle error.""" if _sb is None: raise RuntimeError("No browser session. Call start_browser first.") 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.""" + """Convert SeleniumBase/runtime exceptions into descriptive MCP results. + + Browser automation failures are returned as readable error strings so + an MCP client/LLM can inspect the error and decide whether to retry, + change a selector, wait for a condition, navigate elsewhere, or take + another corrective action. + """ @wraps(func) def wrapper(*args, **kwargs): try: @@ -60,6 +77,7 @@ def wrapper(*args, **kwargs): error_type = e.__class__.__name__ error_msg = str(e).strip() return f"Error in {func.__name__}: {error_type} - {error_msg}" + return wrapper @@ -70,7 +88,7 @@ def wrapper(*args, **kwargs): @mcp.tool() def start_browser( url: str | None = None, - headless: bool = False, + headless: bool | None = None, use_chromium: bool = False, browser_executable_path: str | None = None, incognito: bool = False, @@ -78,37 +96,101 @@ def start_browser( ad_block: bool = False, proxy: str | None = None, ) -> str: - """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. + """Launch a persistent SeleniumBase Pure CDP Mode browser session. + + This must be called before browser interaction tools such as navigate, + get_content, click, fill_input, or find_elements. The same browser + session remains active across subsequent MCP tool calls until + close_browser is called or the server process exits. + + Pure CDP Mode communicates directly with the browser through the Chrome + DevTools Protocol rather than WebDriver. This provides SeleniumBase's + CDP-based browser automation capabilities without using WebDriver as the + browser-control layer. + Args: - url: Optional URL to open immediately on launch. - headless: Run without a visible browser. (Mainly for macOS or Windows - because Xvfb automatically provides a virtual display on Linux.) + url: Optional URL to open immediately after the browser launches. + If omitted, the browser starts without navigating to a requested + page. + + 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. + use_chromium: Use Chromium instead of Google Chrome. This is useful - on environments where Google Chrome is not installed because - SeleniumBase automatically downloads Chromium if it's not found. - browser_executable_path: If Google Chrome is not installed in the - default location, you can set the direct path with this arg. - (This option should not be used if setting use_chromium to True.) - incognito: Launch in a private/incognito window. - guest: Launch in Chrome guest mode. (Don't use with incognito mode) - ad_block: Enables basic ad-blocking functionality. - proxy: Proxy string, e.g. "USER:PASS@SERVER:PORT" or "SERVER:PORT". + when Google Chrome is not installed. SeleniumBase can manage the + Chromium browser when this option is enabled. + + browser_executable_path: Explicit filesystem path to the browser + executable when it is not installed in a standard location. + Do not combine this with use_chromium=True. + + 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". + + Returns: + A confirmation message when the browser starts successfully, including + the effective headless setting, or a descriptive error when browser + startup fails. + + Lifecycle: + Call start_browser once at the beginning of a browser automation + workflow. Reusing the existing session preserves cookies, tabs, + navigation history, localStorage/sessionStorage, and other browser + state between tool calls. Call close_browser when finished. + + Environment requirements: + The MCP runtime must have a compatible Chrome or Chromium browser + available. If the browser executable cannot be discovered, use + use_chromium=True or provide browser_executable_path explicitly. + + On Linux, the default is headless=True so the browser can run in + typical server/container environments without a graphical desktop. + Set headless=False when a graphical display is available and a visible + browser is desired. On Windows and macOS, the default is + headless=False. Set headless=True when running without a desktop or + when a visible browser window is not desired. """ global _sb + if _sb is not None: return ( - "A browser session is already running. Call close_browser first." + "A browser session is already running. " + "Call close_browser first." ) + if incognito and guest: return "Error: incognito and guest cannot both be enabled." + if use_chromium and browser_executable_path: return ( "Error: use_chromium and browser_executable_path " "cannot both be used at the same time." ) - kwargs: dict[str, Any] = {"headless": headless} + + # 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: + effective_headless = sys.platform.startswith("linux") + else: + effective_headless = headless + + kwargs: dict[str, Any] = {"headless": effective_headless} + if use_chromium: kwargs["use_chromium"] = True if browser_executable_path: @@ -121,11 +203,12 @@ def start_browser( kwargs["ad_block"] = True if proxy: kwargs["proxy"] = proxy + try: _sb = sb_cdp.Chrome(url, **kwargs) return ( f"Started Pure CDP Mode browser " - f"(url={url!r}, headless={headless}, " + f"(url={url!r}, headless={effective_headless}, " f"use_chromium={use_chromium})" ) except Exception as e: @@ -135,6 +218,7 @@ def start_browser( except Exception: pass _sb = None + return ( f"Error starting browser: " f"{e.__class__.__name__} - {str(e).strip()}" @@ -143,21 +227,71 @@ def start_browser( @mcp.tool() def close_browser() -> str: - """Close the browser and end the session.""" + """Close the active browser session and release browser resources. + + Call this when the browser automation workflow is finished. Closing the + session ends the persistent browser state, including its open tabs, + cookies, navigation history, and page state. If browser automation is + needed afterward, start a new session with start_browser. + + This operation is safe to call when no browser session is active. + """ global _sb + if _sb is None: return "No browser session was running." + try: _sb.quit() except Exception: pass + _sb = None return "Browser closed." +# --------------------------------------------------------------------------- +# Page information +# --------------------------------------------------------------------------- + @mcp.tool() -def browser_status() -> dict: - """Return whether a browser session is currently active.""" +@handle_sb_errors +def get_page_info() -> dict | str: + """Get current browser session and page metadata. + + Use this as the primary tool for determining where the browser currently + is after navigation, clicks, form submissions, redirects, reloads, or + tab switches. + + This is a READ-ONLY metadata operation. It does not inspect arbitrary + page content, find elements, check visibility, wait for conditions, or + assert expected values. + + Returns: + A dictionary containing: + - running: True when a browser session is active. + - url: The complete current page URL, including path and query string. + - title: The current document title. + - origin: The current page origin (scheme, host, and port). + - user_agent: The browser's current User-Agent string. + - history: The browser navigation history for the current session. + + Tool selection: + - Need URL, title, origin, User-Agent, or navigation history -> + use get_page_info. + - Need visible page text or HTML -> use get_content. + - Need information about matching elements -> use find_elements. + - Need an immediate state check -> use check_state. + - Need to wait for a condition -> use wait_for. + - Need to verify an expected condition -> use assert_that. + + Unlike a dedicated browser-status tool, get_page_info is the single + source of browser/page metadata. If no browser session is active, it + returns {"running": False} instead of attempting to access a page. + + This operation does not navigate, reload, click, type, or otherwise + modify the current page. + """ if _sb is None: return {"running": False} @@ -166,6 +300,9 @@ def browser_status() -> dict: "running": True, "url": _sb.get_current_url(), "title": _sb.get_title(), + "origin": _sb.get_origin(), + "user_agent": _sb.get_user_agent(), + "history": _sb.get_navigation_history(), } except Exception as e: return { @@ -181,13 +318,32 @@ def browser_status() -> dict: @mcp.tool() @handle_sb_errors def navigate(url: str) -> str: - """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.""" + """Navigate the current browser tab to a URL. + + Use this when the browser needs to visit a new URL rather than move + through its existing back/forward history. + + If the URL does not include a protocol such as "https://", SeleniumBase + automatically prefixes "https://" before navigation. For example, + "seleniumbase.io" becomes "https://seleniumbase.io". + + Navigation waits for the initial HTML document to be loaded before + returning. The visited page becomes part of the browser's navigation + history. + + Args: + url: Destination URL. May be a complete URL such as + "https://example.com" or a hostname such as "example.com". + + Returns: + A confirmation containing the requested URL. + + Tool selection: + - Go to a new URL -> use navigate. + - Return to the previous page -> use navigate_history(action="back"). + - Go forward in history -> use navigate_history(action="forward"). + - Refresh the current page -> use navigate_history(action="reload"). + """ _get_sb().get(url) return f"Navigated to {url}" @@ -197,39 +353,53 @@ def navigate(url: str) -> str: def navigate_history( action: Literal["back", "forward", "reload"] = "back", ) -> str: - """Move within browser navigation history, or reload the current page. + """Navigate through the current browser history or reload the page. + + Use this tool only for navigation relative to the current browser + history. Use navigate when going to an arbitrary URL. + Args: - action: 'back', 'forward', or 'reload' (reloads ignoring cache). + action: + - "back": Navigate to the previous history entry. Has no useful + effect when there is no previous history entry. + - "forward": Navigate to the next history entry. Has no useful + effect when there is no forward history entry. + - "reload": Reload the current page while ignoring the browser + cache so page resources are fetched again. + + Returns: + A confirmation message describing the operation performed. + + Notes: + These operations can trigger page loads, redirects, and other + navigation events. Use get_page_info afterward when you need to + verify the resulting URL or title. + + Tool selection: + - Arbitrary destination URL -> use navigate. + - Previous/next browser history entry -> use this tool. + - Refresh current page -> use this tool with action="reload". """ sb = _get_sb() + if action == "back": sb.go_back() return "Navigated back." + if action == "forward": sb.go_forward() return "Navigated forward." + if action == "reload": sb.reload(ignore_cache=True) return "Page reloaded." + return ( f"Error: unknown action '{action}'. " "Use 'back', 'forward', or 'reload'." ) -@mcp.tool() -@handle_sb_errors -def get_page_info() -> dict | str: - """Get the current URL, title, origin, & navigation history in one call.""" - sb = _get_sb() - return { - "url": sb.get_current_url(), - "title": sb.get_title(), - "origin": sb.get_origin(), - "history": sb.get_navigation_history(), - } - - # --------------------------------------------------------------------------- # Finding & reading # --------------------------------------------------------------------------- @@ -238,23 +408,50 @@ def get_page_info() -> dict | str: @handle_sb_errors def find_elements( selector: str, - timeout: int | None = 7, + timeout: int | float | None = 7, include_html: bool = False, ) -> dict | str: - """Find element(s) matching a CSS selector or visible text, and return - their tag name, text, and outer HTML (optional). + """Find matching elements and return structured element information. + + Use this tool when you need to discover how many elements match a + selector, inspect their text/tag names, or inspect the HTML of multiple + matches. + + This tool resolves element handles immediately into ordinary JSON-like + dictionaries. It does not return live SeleniumBase element objects. + Args: - selector: CSS selector, or text to search for (CDP mode can match - elements by visible text as well as by selector, e.g. - 'a:contains("Sign in")'). - timeout: Seconds to wait for at least one match to appear. - include_html: Whether to include the html with each matching element. - Returns a dict with 'count' (total matches found) and 'matches' (a list - of {tag_name, text, html} dicts, or {tag_name, text} dicts if not - including html). + selector: CSS selector, or a SeleniumBase selector that can match + visible text. Examples include "button", ".login-link", or + 'a:contains("Sign in")'. + timeout: Maximum number of seconds to wait for matching elements. + Defaults to 7 seconds. + include_html: If True, include each matching element's outer HTML. + If False, return only tag name and text. + + Returns: + A dictionary containing: + - count: Number of matching elements found. + - matches: A list of element dictionaries containing tag_name and + text, plus html when include_html=True. + + Tool selection: + - Need structured information about matching elements -> + use find_elements. + - Need the visible text/HTML of a page or a single element -> + use get_content. + - Need to click one of several matches -> use click with nth. + - Need to know whether an element is present/visible -> + use check_state. + + Note: + Element handles cannot be persisted across MCP calls. If you find + elements and then need to act on one, resolve it again with the + appropriate interaction tool. """ sb = _get_sb() els = sb.find_all(selector, timeout=timeout) + if include_html: return { "count": len(els), @@ -263,53 +460,118 @@ def find_elements( "tag_name": e.tag_name, "text": e.text, "html": e.get_html(), - } for e in els - ], - } - else: - return { - "count": len(els), - "matches": [ - { - "tag_name": e.tag_name, - "text": e.text, - } for e in els + } + for e in els ], } + return { + "count": len(els), + "matches": [ + { + "tag_name": e.tag_name, + "text": e.text, + } + for e in els + ], + } + @mcp.tool() @handle_sb_errors -def get_page_content( +def get_content( selector: str | None = None, - as_html: bool = False, + output_format: Literal["text", "html", "urls"] = "text", include_shadow_dom: bool = True, -) -> str: - """Get the visible text or HTML of an element, or of the whole page. +) -> str | list[str]: + """Read visible text, HTML, or discovered URLs from the current page. + + Use this tool when you need actual page content or URL information rather + than page metadata. + Args: - selector: Element to read from. Omit (or pass None) to read the - whole page instead of one element. - as_html: If True, return HTML instead of visible text. - include_shadow_dom: Only applies when reading the whole page as HTML. + selector: Optional CSS selector or SeleniumBase text-matching selector + identifying the element whose content should be read. For + output_format="text" or "html", the selector scopes the returned + content to that element. For output_format="urls", the selector + scopes URL discovery to URLs within that element. When omitted, + the operation applies to the whole page. + + output_format: + - "text": Return visible text from the page or selected element. + - "html": Return HTML from the page or selected element. + - "urls": Return all discovered linked/resource URLs on the page + or within the selected element. URLs associated with elements + such as anchors, links, images, scripts, and metadata may be + included. SeleniumBase returns full URLs with their URL + prefixes. + + include_shadow_dom: When output_format="html" and selector is omitted, + include any shadow-root HTML present in the page. This option has + no effect for "text" or "urls", or when a selector is specified. + + Returns: + For output_format="text", a string containing visible text. + For output_format="html", a string containing HTML. + For output_format="urls", a list of URL strings. This is useful for + crawling, link discovery, resource inspection, and finding candidate + URLs before navigating to them. + + Tool selection: + - Need URL, title, origin, User-Agent, or navigation history -> + use get_page_info. + - Need visible text -> use output_format="text". + - Need page or element HTML -> use output_format="html". + - Need URLs from the page or an element -> use output_format="urls". + - Need structured information about matching elements -> + use find_elements. + - Need to check whether an element is present or visible -> + use check_state. + - Need to wait for content to appear -> use wait_for. """ sb = _get_sb() + + if output_format == "urls": + return sb.get_all_urls(selector=selector) + if selector is None: - if as_html: - return sb.get_page_source(include_shadow_dom=include_shadow_dom) + if output_format == "html": + return sb.get_page_source( + include_shadow_dom=include_shadow_dom + ) return sb.get_text("body") - if as_html: + + if output_format == "html": return sb.get_element_html(selector) + return sb.get_text(selector) @mcp.tool() @handle_sb_errors -def get_attributes(selector: str, attribute: str | None = None) -> Any: - """Get one attribute's value from an element, or all of its attributes - as a dict if `attribute` isn't given.""" +def get_attributes( + selector: str, + attribute: str | None = None, +) -> Any: + """Read HTML attributes from a matching element. + + Args: + selector: CSS selector or SeleniumBase text-matching selector for + the target element. + attribute: Specific HTML attribute to retrieve. When omitted, return + all available attributes as a dictionary. + + Returns: + The requested attribute value, or a dictionary containing all + attributes when attribute is omitted. + + This is a read-only operation and does not modify the element. + """ sb = _get_sb() + if attribute: return sb.get_element_attribute(selector, attribute) + return sb.get_element_attributes(selector) @@ -320,44 +582,62 @@ def check_state( selector: str = "body", text: str | None = None, ) -> Any: - """Check the current state of the page or an element. - Unless setting 'count', where it may wait up to 1 second, this never waits. - Never raises exceptions — use wait_for if you want to wait for a state. - For 'count', if there are no matching elements, then it waits up to - 1 second for a single match to appear. If no matches after 1 second, - then 'count' returns 0. + """Immediately inspect the current state of an element or page. + + Use this tool when you need an observation of the current state and do + NOT want to wait for a condition. For waiting behavior, use wait_for. + For an expectation that should fail as an assertion, use assert_that. + Args: - check: 'present', 'visible', 'count', 'text_visible'. - (`text_visible` requires value for `text`.) - selector: The CSS Selector for the chosen check. - text: The text to use for the `text_visible` check. + check: + - "present": Return whether at least one matching element exists. + - "visible": Return whether the matching element is visible. + - "count": Return the number of matching elements. This check may + wait up to 1 second for a match. + - "text_visible": Return whether the specified text is visible + within the selected element. Requires text. + selector: CSS selector or SeleniumBase selector for the element. + Defaults to "body". + text: Text to check when check="text_visible". + + Returns: + A boolean for present/visible/text_visible, or an integer count for + count. Missing elements do not cause an exception for these checks. + + Tool selection: + - Immediate yes/no/count observation -> use check_state. + - Wait until a state becomes true/false -> use wait_for. + - Verify an expected condition and fail when it is not met -> + use assert_that. + + Note: + Except for count's short lookup, this tool does not wait for elements + to appear. Use wait_for when page timing matters. """ sb = _get_sb() + if check == "present": return sb.is_element_present(selector) + if check == "visible": return sb.is_element_visible(selector) + if check == "count": return len(sb.find_elements(selector, timeout=1)) + if check == "text_visible": if text is None: return ( "Error: The 'text_visible' check requires value for 'text'." ) return sb.is_text_visible(text, selector) + return ( f"Error: unknown check '{check}'. " "Use 'present', 'visible', 'count', or 'text_visible'." ) -@mcp.tool() -@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) - - # --------------------------------------------------------------------------- # Interacting with elements # --------------------------------------------------------------------------- @@ -370,69 +650,110 @@ def click( all_matches: bool = False, only_if_visible: bool = False, parent_selector: str | None = None, - timeout: int | None = 7, + timeout: int | float | None = 7, scroll: bool = True, ) -> str: - """Click an element matched by a CSS selector, or by visible text - (e.g. 'a:contains("Sign in")'). + """Click one or more elements matching a selector. + + This is the primary element-clicking tool. The selector may be a CSS + selector or SeleniumBase text-matching selector such as + 'a:contains("Sign in")'. + Args: - nth: Click only the Nth match (1-indexed), when several elements - match. Takes priority over `all_matches`. (`nth` set to 1 is - no different than a regular click, since the first matching - selector would be clicked.) - all_matches: Click every currently-visible match, in order (e.g. - checking every checkbox on a page). Ignored if `nth` is given. - only_if_visible: Only try the click if the element is visible. - Do nothing (no error or wait) if the element isn't currently - visible, instead of waiting for it or raising an exception. - parent_selector: If set, look for `selector` nested inside parent. - (This option can be used to click on elements inside iframes). - timeout: Seconds to wait for the element when doing a basic click - without nth, all_matches, only_if_visible, or parent_selector. - (Defaults to 7 seconds). - scroll: Whether to scroll the element into view before clicking. + selector: Target CSS selector or text-matching selector. + nth: Click only the Nth matching element, using 1-based indexing. + Takes priority over all_matches. + all_matches: Click every currently visible matching element, in order. + Ignored when nth is provided. + only_if_visible: Attempt the click only when the target is already + visible. Does not wait for the element to become visible. + parent_selector: Restrict the nested lookup to a parent element. + Useful for elements inside iframes or nested containers when + supported by SeleniumBase. + timeout: Seconds to wait for a basic click when no specialized mode + is selected. Defaults to 7 seconds. + scroll: Scroll the target into view before clicking. + + Tool selection: + - Click one matching element -> basic click. + - Click a specific matching occurrence -> set nth. + - Click every visible match -> set all_matches=True. + - Click only when already visible -> set only_if_visible=True. + - Click an element nested inside another element -> set + parent_selector. """ sb = _get_sb() + if nth is not None: if nth < 1: return "Error: nth must be >= 1." sb.click_nth_element(selector, nth, scroll=scroll) return f"Clicked match #{nth} of {selector}" + if all_matches: sb.click_visible_elements(selector) return f"Clicked all visible matches of {selector}" + if only_if_visible: sb.click_if_visible(selector) return f"click (only_if_visible) ran for {selector}" + if parent_selector: sb.nested_click(parent_selector, selector) return f"Clicked {selector} inside {parent_selector}" + sb.click(selector, timeout=timeout, scroll=scroll) return f"Clicked {selector}" @mcp.tool() @handle_sb_errors -def hover(selector: str, then_click_selector: str | None = None) -> str: - """Simulate a mouse hover over an element, - optionally then clicking a second element that the hover reveals - (e.g. an item in a dropdown menu). +def hover( + selector: str, + then_click_selector: str | None = None, +) -> str: + """Hover over an element, optionally clicking an element revealed by hover. + + Use this for menus, dropdowns, tooltips, or other interfaces where an + element must first be hovered before its target becomes available. + Args: selector: Element to hover over. - then_click_selector: If given, click this element after hovering. + then_click_selector: Optional element to click after the hover. + Useful for a submenu item or dropdown option revealed by hover. + + Returns: + A confirmation describing the hover/click operation. """ sb = _get_sb() + if then_click_selector: sb.hover_and_click(selector, then_click_selector) return f"Hovered {selector} and clicked {then_click_selector}" + sb.hover_element(selector) return f"Hovered {selector}" @mcp.tool() @handle_sb_errors -def drag_and_drop(source_selector: str, target_selector: str) -> str: - """Drag an element and drop it onto another element.""" +def drag_and_drop( + source_selector: str, + target_selector: str, +) -> str: + """Drag a draggable element and drop it onto another element. + + Drag-and-drop is performed through SeleniumBase's CDP browser controls, + simulating the pointer and mouse events expected by web applications. + + Args: + source_selector: CSS selector identifying the draggable source. + target_selector: CSS selector identifying the drop target. + + The simulated interaction includes events such as pointerdown, + mousedown, dragstart, dragenter, dragover, drop, dragend, mouseup, and + pointerup. + """ _get_sb().drag_and_drop(source_selector, target_selector) return f"Dragged {source_selector} onto {target_selector}" @@ -449,21 +770,35 @@ def fill_input( "fast_type", "clear", ] = "type", - timeout: int | None = 7, + timeout: int | float | None = 7, ) -> str: - """Set the value of an input, textarea, or contenteditable element. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout. + """Enter, append, directly set, or clear text in a form control. + + Use this tool for input elements, textareas, and contenteditable elements. + Args: - mode: 'type' (default) clears the field then types `text`; - 'append' sends `text` as keystrokes without clearing first; - 'set_value' sets the value directly and instantly (good for - sliders and other fast form fills; skips key events); - 'fast_type' clears the field then types `text` fast - (good for when the extra stealth doesn't matter); - 'clear' empties the field — `text` is ignored. + selector: CSS selector or SeleniumBase selector identifying the + input, textarea, or contenteditable element. + text: Text to enter or set. Ignored when mode="clear". + mode: + - "type": Clear the field and type text normally. + - "append": Keep the existing value and send text as keystrokes. + - "set_value": Set the value directly and immediately. This can + be useful for fast form filling but does not simulate normal + key events. + - "fast_type": Clear the field and type text quickly. + - "clear": Empty the field; text is ignored. + timeout: Maximum seconds to wait for the target element. + + Tool selection: + - Normal human-like text entry -> mode="type". + - Add text without clearing -> mode="append". + - Directly set a value -> mode="set_value". + - Fast typing -> mode="fast_type". + - Empty a field -> mode="clear". """ sb = _get_sb() + if mode == "type": sb.type(selector, text, timeout=timeout) elif mode == "append": @@ -479,6 +814,7 @@ def fill_input( f"Error: unknown mode '{mode}'. " "Use 'type', 'append', 'set_value', 'fast_type', or 'clear'." ) + return f"fill_input(mode={mode!r}) done for {selector}" @@ -489,16 +825,27 @@ def select_option( value: str | int, by: Literal["text", "value", "index"] = "text", ) -> str: - """Select an option in a dropdown. + Args: - value: The option's visible text, its `value` attribute, or its - 0-based index (as a string), depending on `by`. - by: 'text' (default), 'value', or 'index'. - Using "index" for `by` is type-safe. (Eg. 4 and "4" both work the same) + dropdown_selector: CSS selector identifying the elements. For custom JavaScript + dropdowns made from div/button/list elements, use click or other + element-interaction tools instead. """ sb = _get_sb() + if by == "text": sb.select_option_by_text(dropdown_selector, str(value)) elif by == "value": @@ -507,24 +854,46 @@ def select_option( sb.select_option_by_index(dropdown_selector, int(value)) else: return f"Error: unknown by='{by}'. Use 'text', 'value', or 'index'." + return f"Selected ({by}={value!r}) in {dropdown_selector}" @mcp.tool() @handle_sb_errors -def element_action( +def act_on_element( selector: str, - action: Literal["focus", "highlight", "scroll_into_view"] = "focus", + action: Literal[ + "focus", + "highlight", + "scroll_into_view", + ] = "focus", ) -> str: - """Perform a simple positioning/emphasis action on an element. - Raises an exception if the element isn't found within the default - timeout. + """Perform a non-click positioning or debugging action on an element. + + Use this tool when an element needs to be focused, highlighted for human + observation/debugging, or scrolled into the viewport. + + This tool does NOT click, type into, select from, hover over, or otherwise + activate the element. + Args: - action: 'focus' (move keyboard focus to it), 'highlight' (briefly - flash it using JavaScript — useful for narrating actions - on a visible/headed browser), or 'scroll_into_view'. + selector: CSS selector or SeleniumBase selector identifying the target. + 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: + - Click -> use click. + - Type into a form control -> use fill_input. + - Hover -> use hover. + - Focus, highlight, or scroll without activating -> use + act_on_element. """ sb = _get_sb() + if action == "focus": sb.find_element(selector).focus() elif action == "highlight": @@ -536,6 +905,7 @@ def element_action( f"Error: unknown action '{action}'. " "Use 'focus', 'highlight', or 'scroll_into_view'." ) + return f"{action} done for {selector}" @@ -546,29 +916,54 @@ def element_action( @mcp.tool() @handle_sb_errors def wait_for( - state: Literal["present", "visible", "not_visible", "absent"] = "visible", + state: Literal[ + "present", + "visible", + "not_visible", + "absent", + ] = "visible", selector: str | None = None, text: str | None = None, - timeout: int | None = 7, + timeout: int | float | None = 7, ) -> str: - """Wait for an element or text to reach a given state before returning. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the state isn't reached within the timeout. + """Wait until an element or text reaches a requested state. + + Use this tool when the page is dynamic and an automation step must wait + for a condition before continuing. + + Unlike check_state, this tool intentionally waits. Unlike assert_that, + its purpose is synchronization rather than validating a test expectation. + Args: - state: 'present', 'visible', 'not_visible', or 'absent' — describes - what `selector` should reach. Ignored if `text` is given. - selector: Element to wait on. - Defaults to 'body' when waiting on `text`. - text: If given, waits for this text to appear within `selector` - instead of waiting on the element's presence/visibility. - timeout: Seconds to wait. + state: + - "present": Wait until the matching element exists. + - "visible": Wait until the matching element is visible. + - "not_visible": Wait until the matching element is not visible. + - "absent": Wait until the matching element no longer exists. + Ignored when text is provided. + selector: CSS selector or SeleniumBase selector for the element. + Required unless text is supplied. + text: If supplied, wait for this text to appear within selector + (or within "body" when selector is omitted). + timeout: Maximum seconds to wait. Defaults to 7 seconds. + + Returns: + A confirmation when the requested condition is reached. + + Tool selection: + - Check current state immediately -> use check_state. + - Wait for a state/content transition -> use wait_for. + - Verify an expected value/condition -> use assert_that. """ sb = _get_sb() + if selector is None and text is None: return "Error: `selector` and `text` cannot both be None." + if text is not None: sb.wait_for_text(text, selector or "body", timeout=timeout) return f"Text '{text}' appeared in {selector or 'body'}." + if state == "present": sb.wait_for_element_present(selector, timeout=timeout) elif state == "visible": @@ -582,6 +977,7 @@ def wait_for( f"Error: unknown state '{state}'. " "Use 'present', 'visible', 'not_visible', or 'absent'." ) + return f"Element {selector} reached state '{state}'." @@ -594,41 +990,63 @@ def assert_that( "text", "title", "url", - "url_contains" + "url_contains", ] = "element_visible", selector: str | None = None, expected: str | None = None, exact: bool = False, - timeout: int | None = 7, + timeout: int | float | None = 7, ) -> str: - """Assert a condition about the page or an element. Raises an exception - (surfaced back as an error string) if the assertion fails within the - timeout (default 7 seconds). `timeout` applies only to element/text checks. - (For url or title checks, the assertion either passes or fails right away.) + """Verify an expected browser condition and fail when it is not met. + + Use this tool for explicit verification. Unlike check_state, which simply + reports the current state, assert_that treats a failed expectation as an + error. Unlike wait_for, URL/title checks do not wait. + Args: - check: 'element_present', 'element_visible' (need `selector`); - 'text' (substring of `expected` within `selector`, default - 'html'); 'title', 'url' (exact match), or 'url_contains' - (need `expected`). - selector: Element to check. Used by 'element_present', - 'element_visible', and 'text'. - expected: The text/title/url to check against. Not used for the - element-only checks. - exact: For check='text', require an exact match instead of a - substring match. - timeout: Seconds to wait before failing. + check: + - "element_present": Verify selector identifies a present element. + - "element_visible": Verify selector identifies a visible element. + - "text": Verify expected text within selector, or within the + whole HTML document when selector is omitted. + - "title": Verify the exact page title. + - "url": Verify the exact current URL. + - "url_contains": Verify that the current URL contains expected. + selector: Element selector for element_present, element_visible, and + text checks. + expected: Expected text/title/URL value for text, title, url, and + url_contains. + exact: For check="text", require exact text rather than a substring. + timeout: Maximum seconds to wait for element/text checks. + + Returns: + A confirmation when the expectation passes. + + Raises: + An assertion-related SeleniumBase exception when the expectation + fails; the MCP error wrapper converts it to a descriptive result. + + Tool selection: + - Just inspect current state -> use check_state. + - Wait for a condition to become true -> use wait_for. + - Verify that an expected condition is true -> use assert_that. """ sb = _get_sb() + if check in ("element_present", "element_visible") and selector is None: return f"Error: check='{check}' requires value for `selector`." + if check in ("text", "title", "url", "url_contains") and expected is None: return f"Error: check='{check}' requires value for `expected`." + if check == "element_present": sb.assert_element(selector, timeout=timeout) return f"Confirmed {selector} is present." + if check == "element_visible": sb.assert_element_visible(selector, timeout=timeout) return f"Confirmed {selector} is visible." + if check == "text": target = selector or "html" if exact: @@ -636,15 +1054,19 @@ def assert_that( else: sb.assert_text(expected, target, timeout=timeout) return f"Confirmed text in {target}." + if check == "title": sb.assert_title(expected) return f"Confirmed title is '{expected}'." + if check == "url": sb.assert_url(expected) return f"Confirmed URL is '{expected}'." + if check == "url_contains": sb.assert_url_contains(expected) return f"Confirmed URL contains '{expected}'." + return ( f"Error: unknown check '{check}'. Use 'element_present', " f"'element_visible', 'text', 'title', 'url', or 'url_contains'." @@ -659,27 +1081,67 @@ def assert_that( @handle_sb_errors def manage_cookies( action: Literal["get_all", "clear", "save", "load"] = "get_all", - filename: str = "cookies.txt" + filename: str = "cookies.txt", ) -> Any: - """Get, clear, save, or load browser cookies. + """Manage cookies for the current browser session. + + Use this tool to inspect, clear, save, or restore browser cookies. Cookie + management is useful for inspecting session state, preserving login + sessions between browser runs, restoring previously saved sessions, or + resetting website state during testing. + Args: - action: 'get_all', 'clear', 'save' (to `filename`), or 'load' - (from `filename`). - SECURITY: `filename` can potentially expose filesystem operations - to an MCP client. Existing files could get overwritten via 'save'. + action: + - "get_all": Return all cookies currently available to the browser, + including attributes such as name, value, domain, path, expiry, + and security flags. + - "clear": Delete all cookies from the current browser session. + - "save": Save current cookies to filename. The file may be + created or overwritten. + - "load": Load cookies from filename into the current browser + session. + filename: Filesystem path used by save/load. Defaults to + "cookies.txt". Ignored for get_all and clear. + + Returns: + "get_all": Current browser cookies. + "clear": Confirmation that cookies were cleared. + "save": Confirmation containing the destination filename. + "load": Confirmation containing the source filename. + + Security: + Cookie data can contain authentication credentials, session + identifiers, and other private information. Only inspect, save, + load, or share cookies when explicitly authorized. + + `filename` is passed to SeleniumBase's cookie persistence methods and + can access the filesystem available to the MCP server. Use only + trusted, authorized paths. The save action may overwrite an existing + file. + + Notes: + Loading saved cookies does not guarantee restoration of a login. + Cookies may be expired, invalidated, domain/path restricted, or + dependent on other browser state. Navigate to the relevant site when + necessary so the browser has the appropriate origin for the cookies. """ sb = _get_sb() + if action == "get_all": return sb.get_all_cookies() + if action == "clear": sb.clear_cookies() return "Cookies cleared." + if action == "save": sb.save_cookies(name=filename) return f"Cookies saved to {filename}" + if action == "load": sb.load_cookies(name=filename) return f"Cookies loaded from {filename}" + return ( f"Error: unknown action '{action}'. " "Use 'get_all', 'clear', 'save', or 'load'." @@ -694,28 +1156,46 @@ def manage_storage( storage: Literal["local", "session"] = "local", action: Literal["get", "set"] = "get", ) -> Any: - """Get or set a key in the page's localStorage or sessionStorage. + """Get or set a key in localStorage or sessionStorage. + Args: - storage: 'local' or 'session'. - action: 'get' or 'set'. ('set' requires `value`). - WARNING: This tool can expose authentication/session secrets. - Only use against trusted sites and MCP clients. + key: Storage key to read or modify. + value: Value to store when action="set". Required for set. + storage: "local" for localStorage or "session" for sessionStorage. + action: "get" to read the key or "set" to write the key. + + Returns: + The stored value for get, or a confirmation message for set. + + Security: + Web storage can contain authentication tokens, session identifiers, + and other sensitive application state. Only use this tool with trusted + sites and authorized MCP clients. + + Notes: + Storage belongs to the current page origin. Values from one website + are not generally available to another origin. """ sb = _get_sb() + if action not in ("get", "set"): return "Error: action must be 'get' or 'set'." + if action == "set" and value is None: return "Error: value is required when action='set'." + if storage == "local": if action == "get": return sb.get_local_storage_item(key) sb.set_local_storage_item(key, value) return f"Set localStorage[{key!r}]" + if storage == "session": if action == "get": return sb.get_session_storage_item(key) sb.set_session_storage_item(key, value) return f"Set sessionStorage[{key!r}]" + return f"Error: unknown storage '{storage}'. Use 'local' or 'session'." @@ -729,12 +1209,23 @@ def scroll( direction: Literal["up", "down", "top", "bottom"] = "down", amount: int = 25, ) -> str: - """Scroll the page. + """Scroll the current page vertically. + Args: - direction: 'up' or 'down' (relative, by `amount`), 'top', or 'bottom'. - amount: Relative scroll distance; only used for 'up'/'down'. + direction: + - "up": Scroll upward by amount percent of the window height. + - "down": Scroll downward by amount percent of the window height. + - "top": Scroll directly to the top; amount is ignored. + - "bottom": Scroll directly to the bottom; amount is ignored. + amount: Percentage of the current viewport height used for relative + 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. """ sb = _get_sb() + if direction == "up": sb.scroll_up(amount=amount) elif direction == "down": @@ -748,6 +1239,7 @@ def scroll( f"Error: unknown direction '{direction}'. " "Use 'up', 'down', 'top', or 'bottom'." ) + return f"Scrolled {direction}." @@ -769,25 +1261,41 @@ def manage_window( width: int | None = None, height: int | None = None, ) -> Any: - """Get or change the browser window's size, position, or state. + """Get or change browser window geometry and state. + Args: - action: 'get_rect', 'set_rect' (requires x, y, width, height), - 'maximize', or 'minimize'. + action: + - "get_rect": Return the current window coordinates and size. + - "set_rect": Set x, y, width, and height. All four are required. + - "maximize": Maximize the browser window. + - "minimize": Minimize the browser window. + x: Horizontal screen position for set_rect. + y: Vertical screen position for set_rect. + width: Window width for set_rect. + height: Window height for set_rect. + + Use this tool for browser-window geometry/state. For switching between + browser tabs, use manage_tabs instead. """ sb = _get_sb() + if action == "get_rect": return sb.get_window_rect() + if action == "set_rect": if None in (x, y, width, height): return "Error: set_rect requires x, y, width, and height." sb.set_window_rect(x, y, width, height) return f"Window set to ({x}, {y}, {width}x{height})" + if action == "maximize": sb.maximize() return "Window maximized." + if action == "minimize": sb.minimize() return "Window minimized." + return ( f"Error: unknown action '{action}'. " "Use 'get_rect', 'set_rect', 'maximize', or 'minimize'." @@ -809,48 +1317,72 @@ def manage_tabs( switch_to: bool = True, ) -> Any: """List, open, switch between, or close browser tabs. + + Use this tool for tab management. Browser navigation within the current + tab belongs to navigate and navigate_history. + Args: - action: 'list' (returns each open tab's index/url/title — call this - before 'switch' to find the right tab_index), 'open' (a new - tab, optionally navigating to `url`), 'switch' (to `tab_index`), - 'switch_newest', or 'close_active'. - url: Used with action='open'. - tab_index: Used with action='switch'; the index as returned by 'list'. - switch_to: Used with action='open'; whether to switch to the new tab. + action: + - "list": Return each open tab's index, URL, and title. Call this + before switch when you need to determine a tab_index. + - "open": Open a new tab, optionally navigating it to url. + - "switch": Switch to the tab identified by tab_index from list. + - "switch_newest": Switch to the newest tab. + - "close_active": Close the currently active tab. + url: URL for action="open". + tab_index: Index returned by action="list" for action="switch". + switch_to: For action="open", switch to the newly created tab when + True. + + Notes: + Clicking a link or performing another browser action may open a new + tab. Use action="list" to inspect available tabs before switching by + index. Tab indexes should be treated as current-session values and + may change after tabs are opened or closed. """ sb = _get_sb() + if action == "list": tabs = sb.get_tabs() return [ { - "index": i, "url": getattr(t, "url", None), - "title": getattr(t, "title", None) + "index": i, + "url": getattr(t, "url", None), + "title": getattr(t, "title", None), } for i, t in enumerate(tabs) ] + if action == "open": sb.open_new_tab(url=url, switch_to=switch_to) return f"Opened new tab (url={url!r}, switch_to={switch_to})" + if action == "switch": if tab_index is None: return ( "Error: action='switch' requires tab_index " "(see action='list')." ) + tabs = sb.get_tabs() + if tab_index < 0 or tab_index >= len(tabs): return ( f"Error: tab_index={tab_index} out of range. " f"Available indexes: 0-{len(tabs) - 1}." ) + sb.switch_to_tab(tabs[tab_index]) return f"Switched to tab {tab_index}" + if action == "switch_newest": sb.switch_to_newest_tab() return "Switched to newest tab." + if action == "close_active": sb.close_active_tab() return "Closed active tab." + return ( f"Error: unknown action '{action}'. Use 'list', 'open', 'switch', " f"'switch_newest', or 'close_active'." @@ -864,9 +1396,29 @@ def manage_tabs( @mcp.tool() @handle_sb_errors def solve_captcha() -> str: - """Attempt to solve a CAPTCHA (e.g. Cloudflare Turnstile) on the page.""" + """Attempt a SeleniumBase CDP-based CAPTCHA interaction. + + This tool attempts to interact with CAPTCHA controls such as Cloudflare + Turnstile, reCAPTCHA, or FriendlyCaptcha using browser/CDP interaction. + + The tool does not guarantee that a CAPTCHA was solved. Some CAPTCHA + controls are embedded inside shadow DOM or otherwise do not expose an + easy success signal. A successful attempt may result in changes to page + state or browser cookies. + + Tool workflow: + 1. Inspect the page with get_content when you need to determine + whether CAPTCHA-related controls are present. + 2. Call solve_captcha to attempt the interaction. + 3. Use get_page_info, get_content, check_state, or manage_cookies + to inspect resulting page/session state. + + Returns: + A message confirming that the CAPTCHA interaction was attempted, not + a guarantee that the CAPTCHA challenge was solved. + """ _get_sb().solve_captcha() - return "Attempted captcha solve." + return "Attempted CAPTCHA solve." # --------------------------------------------------------------------------- @@ -878,21 +1430,33 @@ def solve_captcha() -> str: def save_output( format: Literal["screenshot", "html", "pdf"] = "screenshot", filename: str | None = None, - folder: str | None = None + folder: str | None = None, ) -> str: - """Save the current page as a screenshot, HTML source, or PDF file. + """Save the current browser page as a screenshot, HTML file, or PDF. + + Use this tool when an automation workflow needs a persistent artifact + from the current page, such as a screenshot for debugging, page source + for inspection, or a PDF representation. + Args: - format: 'screenshot', 'html', or 'pdf'. - filename: Output filename. Defaults to 'screenshot.png', - 'page_source.html', or 'page.pdf' depending on `format`. - folder: Optional folder to save into. - If 'screenshot' format, then the page is saved as a PNG (.png) file. - If 'html' format, then the page source is saved to an HTML (.html) file. - If 'pdf' format, then the page is saved as a PDF (.pdf) file. - SECURITY: `filename`/`folder` can potentially expose filesystem operations - to an MCP client. Existing files could get overwritten with the save. + format: + - "screenshot": Save a PNG screenshot. + - "html": Save the current page source as HTML. + - "pdf": Save the current page as a PDF. + filename: Output filename. Defaults to screenshot.png, + page_source.html, or page.pdf depending on format. + folder: Optional destination folder. + + Returns: + A confirmation containing the output format and filename. + + Security: + filename and folder can affect filesystem paths available to the MCP + server. Existing files may be overwritten. Use trusted, authorized + paths only. """ sb = _get_sb() + if format == "screenshot": name = filename or "screenshot.png" sb.save_screenshot(name, folder=folder) @@ -907,36 +1471,60 @@ def save_output( f"Error: unknown format '{format}'. " "Use 'screenshot', 'html', or 'pdf'." ) + return f"Saved {format} as {name}" @mcp.tool() @handle_sb_errors def run_javascript(expression: str) -> Any: - """Evaluate a JavaScript expression in the page context and return the - result. This method can run any arbitrary JavaScript on any visited site. - SECURITY: This provides unrestricted JavaScript execution in the browser - context. Only expose this MCP server to trusted clients.""" + """Evaluate arbitrary JavaScript in the current page context. + + Use this only when the required browser operation cannot be accomplished + through the higher-level SeleniumBase tools. + + The expression is evaluated through the Chrome DevTools Protocol + Runtime.evaluate mechanism. Promise results are awaited and values are + returned by value. + + Args: + expression: JavaScript expression to evaluate in the current page + context. + + Returns: + The JavaScript evaluation result when it can be represented across + the MCP boundary. + + Security: + This provides unrestricted JavaScript execution in the current browser + page. It can read or modify page data and interact with the page in + ways that bypass the higher-level tool abstractions. Only expose this + MCP server to trusted clients. + """ return _get_sb().evaluate(expression) @mcp.tool() @handle_sb_errors -def wait_seconds(seconds: float) -> str: - """Pause execution for a number of seconds.""" - _get_sb().sleep(seconds) - return f"Waited {seconds}s" +def wait_seconds(seconds: int | float) -> str: + """Block the MCP server for a fixed number of seconds. + This is a low-level timing tool. It performs no browser action while + waiting and should not be used when waiting for a page condition. -@mcp.tool() -@handle_sb_errors -def get_user_agent() -> str: - """Get the browser's current user agent string.""" - return _get_sb().get_user_agent() + Prefer wait_for when waiting for an element or text to appear/disappear, + because wait_for can return as soon as the requested condition is met. + + Args: + seconds: Number of seconds to block. May be an integer or float. + """ + _get_sb().sleep(seconds) + return f"Waited {seconds}s" def _cleanup_browser(): global _sb + if _sb is not None: try: _sb.quit() @@ -948,10 +1536,14 @@ def _cleanup_browser(): def main(): atexit.register(_cleanup_browser) print(f'Running the "{mcp.name}" server...', file=sys.stderr) + try: mcp.run(transport="stdio") except (KeyboardInterrupt, SystemExit): - print(f'\nThe "{mcp.name}" server was stopped.', file=sys.stderr) + print( + f'\nThe "{mcp.name}" server was stopped.', + file=sys.stderr, + ) sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index 7ed8ecc..89c60fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ SeleniumBase = "https://github.com/seleniumbase/SeleniumBase" [dependency-groups] # Used by `uv sync` dev = [ - "uv>=0.12.7", # Needed for `mcp dev` + "uv>=0.12.8", # Needed for `mcp dev` ] [build-system] diff --git a/requirements.txt b/requirements.txt index 24aceb2..8169d9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -seleniumbase[mcp]>=4.53.1 +seleniumbase[mcp]>=4.53.3 mcp[cli]>=2.1.1,<3.0.0 diff --git a/setup.py b/setup.py index 3d688ac..36d50ed 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,9 @@ sys.exit() print("\n*** Checking code health with flake8:\n") os.system("python -m pip install 'flake8==7.3.0'") - flake8_status = os.system("flake8 --exclude=recordings,temp") + flake8_status = os.system( + "flake8 --exclude=recordings,temp,venv,.venv" + ) if flake8_status != 0: print("\nERROR! Fix flake8 issues before publishing to PyPI!\n") sys.exit() @@ -68,7 +70,7 @@ setup( name="seleniumbase-mcp", - version="1.2.0", + version="1.2.1", description="MCP servers exposing SeleniumBase as tools for MCP clients.", long_description=long_description, long_description_content_type="text/markdown", @@ -138,7 +140,7 @@ ], python_requires=">=3.10", install_requires=[ - "seleniumbase[mcp]>=4.53.1", + "seleniumbase[mcp]>=4.53.3", "mcp[cli]>=2.1.1,<3.0.0", ], extras_require={ @@ -147,10 +149,10 @@ "twine>=7.0.0", ], "uv": [ - "uv>=0.12.7", + "uv>=0.12.8", ], "dev": [ - "uv>=0.12.7", + "uv>=0.12.8", ], }, entry_points={