diff --git a/mcp_servers/README.md b/mcp_servers/README.md index ffefa1eb7e4..5fe9e81fa02 100644 --- a/mcp_servers/README.md +++ b/mcp_servers/README.md @@ -99,10 +99,10 @@ Restart Claude Desktop. You should see a 🔨 tools icon indicating the server c * `get_attributes` * `check_condition` * `click` -* `hover_with_action` +* `hover_action` * `type_text` * `select_option` -* `focus_on` +* `focus` * `wait_for` * `assert_condition` * `manage_cookies` @@ -156,7 +156,7 @@ Tools here are grouped around a shared `selector` convention: `selector` args ac | Session | `start_browser(url, headless, use_chromium, browser_executable_path, incognito, guest, ad_block, proxy)`, `close_browser` | | Navigation | `navigate`, `manage_history(action: back/forward/reload/list)`, `get_page_info` (running status, url, title, origin, user agent in one call) | | Finding & reading | `find_elements(selector, timeout, include_html)`, `get_content(selector, output_format: text/html/urls, include_shadow_dom)`, `get_attributes`, `check_condition(check: present/visible, text)` | -| Interacting | `click(selector, nth, all_matches, only_if_visible, parent_selector, timeout, scroll)`, `hover_with_action(selector1, selector2, action: none/click/drag_and_drop)`, `type_text(mode: fill_input/append/fast_type/set_value/clear_only)`, `select_option(by: text/value/index)`, `focus_on(action: scroll_to_element/focus/highlight)` | +| Interacting | `click(selector, nth, all_matches, only_if_visible, parent_selector, timeout, scroll)`, `hover_action(selector1, selector2, action: none/click/drag_and_drop)`, `type_text(mode: fill_input/append/fast_type/set_value/clear_only)`, `select_option(by: text/value/index)`, `focus(action: scroll_to_element/focus/highlight)` | | Waiting | `wait_for(state: present/visible/not_visible/absent, text)` | | Assertions | `assert_condition(check: element_present/element_visible/text_visible/title/url/url_contains)` | | Cookies & storage | `manage_cookies(action: get_all/clear/save/load)`, `manage_storage(storage: local/session, action: get/set)` | @@ -173,7 +173,7 @@ Tools here are grouped around a shared `selector` convention: `selector` args ac - **`start_browser` retries once before failing.** If the first launch attempt raises, it's retried once automatically before returning an error. This was added after seeing occasional first-attempt failures when testing against Glama's MCP Inspector; it costs nothing on the common case where the first launch already succeeds. -- **Errors surface as descriptive strings.** Every tool (aside from session-lifecycle tools, which handle their own errors) is wrapped by a `handle_sb_errors` decorator: if a selector isn't found or an assertion fails, `sb_cdp.Chrome` raises an exception, and the decorator catches it and returns a string like `Error in click: NoSuchElementException - ...` instead of a raw tool error. This lets the calling agent read the failure and self-correct (e.g. by waiting longer or trying a different selector) rather than just seeing an opaque tool-call failure. +- **Two error-handling paths, by design.** Most failures (a selector isn't found, an assertion fails, an invalid `action`/`mode`/`check` value is passed) are caught by the `handle_sb_errors` decorator and returned as a descriptive string, e.g. `Error in click: NoSuchElementException - ...`, so the calling agent can read the failure and self-correct. There's one deliberate exception: calling any tool other than `start_browser`/`close_browser` when no browser session is running raises `ToolError` (via the shared `_get_sb()` helper) instead of returning a string. `handle_sb_errors` explicitly re-raises `ToolError` rather than catching it, so this surfaces to the MCP client as a real tool-call error (`is_error=True`), not as ordinary text the agent has to pattern-match on. `start_browser` and `close_browser` handle their own lifecycle errors directly (e.g. "already running", a failed `quit()`) and also return strings rather than raising. - **No standalone session-status tool.** There is no separate `browser_status`-style tool. `get_page_info` doubles as the status check: it returns `{"running": False}` (optionally with an `error` field) when there's no active session or the session errors out, and page metadata (`running: True`, `url`, `title`, `origin`, `user_agent`) otherwise. `get_page_info` does not include navigation history — that lives on `manage_history(action="list")` instead (see below). @@ -183,11 +183,11 @@ Tools here are grouped around a shared `selector` convention: `selector` args ac - **`check_condition` is deliberately narrow.** Its `check` parameter only accepts `"present"` or `"visible"` — there's no built-in `"count"` check anymore; call `find_elements` and read the returned `count` field instead. Passing `text` checks whether that text is visible within `selector` and takes priority over `check` when both are given — so `check_condition(text="Sign in")` behaves differently from `check_condition(check="visible")`, not as two variants of the same check. Note that an empty string for `text` (or for `wait_for`'s `selector`/`text`) is treated as not provided, since both tools now branch on truthiness rather than on `is not None`. -- **`find_elements` defaults to a fast, non-raising lookup.** Its default `timeout` is 0.5 seconds (not 7, unlike most other tools here), and a failed lookup returns `{}` instead of raising — there is no error string on a miss, just an empty dict. Pass a longer `timeout` explicitly if the elements you're looking for may still be loading. +- **`find_elements` defaults to a fast, non-raising lookup.** Its default `timeout` is 0.5 seconds (not 7, unlike most other tools here), and a failed lookup returns `{"count": 0, "matches": []}` instead of raising — there is no error string on a miss, just an empty result. Pass a longer `timeout` explicitly if the elements you're looking for may still be loading. -- **Hover, click-after-hover, and drag-and-drop share one tool.** `hover_with_action(selector1, selector2, action)` replaces the earlier separate `hover` and `drag_and_drop` tools. `action="none"` hovers `selector1` only; `action="click"` hovers `selector1` then clicks `selector2` (useful for dropdown/submenu items revealed by hovering); `action="drag_and_drop"` drags `selector1` onto `selector2`. (`selector2` is required when `action` is `"click"` or `"drag_and_drop"`.) +- **Hover, click-after-hover, and drag-and-drop share one tool.** `hover_action(selector1, selector2, action)` replaces the earlier separate `hover` and `drag_and_drop` tools. `action="none"` hovers `selector1` only; `action="click"` hovers `selector1` then clicks `selector2` (useful for dropdown/submenu items revealed by hovering); `action="drag_and_drop"` drags `selector1` onto `selector2`. (`selector2` is required when `action` is `"click"` or `"drag_and_drop"`.) -- **Non-activating element actions are `focus_on`.** What used to be `act_on_element` is now `focus_on(selector, action)`, with actions `scroll_to_element` (the default), `focus`, and `highlight` — note the default action changed from focusing the element to scrolling it into view. None of these actions click, type into, select from, or otherwise activate the element; use `click`, `type_text`, `select_option`, or `hover_with_action` for that. +- **Non-activating element actions are `focus`.** What used to be `act_on_element` is now `focus(selector, action)`, with actions `scroll_to_element` (the default), `focus`, and `highlight` — note the default action is scrolling the element into view, not focusing it. None of these actions click, type into, select from, or otherwise activate the element; use `click`, `type_text`, `select_option`, or `hover_action` for that. - **Elements don't cross the wire as handles.** In native CDP Mode, `find_element()` returns a live object with its own methods (`el.click()`, `el.get_html()`, ...). MCP tools can only return JSON-serializable data, so `find_elements` resolves each match immediately to a plain dict (`tag_name`, `text`, and optionally `html`) instead of returning a handle you could call further methods on. If you need to act on one of several matches, use `click(selector, nth=...)` (acts by position) rather than "find, then click" as two separate steps. diff --git a/mcp_servers/pyproject.toml b/mcp_servers/pyproject.toml index 9764d6e8198..e89e50a463f 100644 --- a/mcp_servers/pyproject.toml +++ b/mcp_servers/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "seleniumbase-mcp" -version = "1.2.5dev0" +version = "1.2.6dev0" description = "MCP server exposing SeleniumBase CDP Mode as tools for MCP clients." readme = "README.md" requires-python = ">=3.10" license = "MIT" dependencies = [ - "mcp[cli]>=2.1.1,<3.0.0", + "mcp[cli]>=2.2.0,<3.0.0", "seleniumbase", ] keywords = [ diff --git a/mcp_servers/requirements.txt b/mcp_servers/requirements.txt index 4f234f745c9..1bfab2e086a 100644 --- a/mcp_servers/requirements.txt +++ b/mcp_servers/requirements.txt @@ -1,4 +1,4 @@ -mcp[cli]>=2.1.1,<3.0.0 +mcp[cli]>=2.2.0,<3.0.0 -e .. # `-e ..` installs SeleniumBase itself from the repo root (one directory diff --git a/mcp_servers/server.py b/mcp_servers/server.py index c954227fa20..0b94e464cd3 100644 --- a/mcp_servers/server.py +++ b/mcp_servers/server.py @@ -13,11 +13,18 @@ Model: One persistent `sb_cdp.Chrome` session per server process. Call start_browser once; drive it with the other tools; then close_browser. +Selectors: +- CSS selectors are preferred. (All tools that take selectors support CSS.) +- XPath is accepted in several (but not all) cases. Some methods utilize + the Chrome DevTools protocol, where Xpath might not be accepted unless + SeleniumBase converts those XPath selectors into valid CSS selectors first. +- Special SeleniumBase selector syntax may be accepted by individual tools, + such as the visible text selector, e.g. `a:contains("Sign in")`. +- Tool-specific documentation takes precedence when selector behavior differs. + 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. +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. @@ -31,7 +38,7 @@ - Use wait_for when the agent needs to wait for a condition to become true. - Use assert_condition when the agent needs to verify an expected condition and treat failure as an assertion error. -- Use click/type_text/select_option/hover_with_action/focus_on for +- Use click/type_text/select_option/hover_action/focus for interactions and element positioning. """ from __future__ import annotations @@ -40,6 +47,7 @@ from functools import wraps from typing import Any, Literal from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from seleniumbase import sb_cdp mcp = MCPServer("seleniumbase-mcp") @@ -50,7 +58,7 @@ 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.") + raise ToolError("No browser session. Call start_browser first.") return _sb @@ -66,6 +74,8 @@ def handle_sb_errors(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) + except ToolError: + raise except Exception as e: error_type = e.__class__.__name__ error_msg = str(e).strip() @@ -159,10 +169,7 @@ def start_browser( global _sb if _sb is not None: - return ( - "A browser session is already running. " - "Call close_browser first." - ) + return ("A browser session is already running.") if incognito and guest: return "Error: incognito and guest cannot both be enabled." @@ -247,15 +254,19 @@ def close_browser() -> str: global _sb if _sb is None: - return "No browser session was running." + return "No browser session is currently running." try: _sb.quit() - except Exception: - pass + except Exception as e: + return ( + "Error calling `quit()` on the browser session: " + f"{e.__class__.__name__} - {str(e).strip()}" + ) + finally: + _sb = None - _sb = None - return "Browser closed." + return "The browser session was closed." # --------------------------------------------------------------------------- @@ -264,7 +275,7 @@ def close_browser() -> str: @mcp.tool() @handle_sb_errors -def get_page_info() -> dict | str: +def get_page_info() -> dict: """Get current browser session and page metadata. Use this as the primary tool for determining where the browser currently @@ -277,7 +288,10 @@ def get_page_info() -> dict | str: Returns: A dictionary containing: - - running: True when a browser session is active. + - running: True when a browser session is active and metadata exists. + False if not running or there was a problem getting all metadata, + where possibly the browser crashed and therefore internally the + tool didn't realize that the browser was no longer running. - 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). @@ -341,7 +355,7 @@ def navigate(url: str) -> str: "https://example.com" or a hostname such as "example.com". Returns: - A confirmation containing the requested URL. + A confirmation message containing the requested URL. Tool selection: - Go to a new URL -> use navigate. @@ -373,8 +387,8 @@ def manage_history( 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. - - "list": Return a tuple containing the current location in - history (0-indexed) and the full navigation-history list. + - "list": Return a dictionary containing the current location + in history (0-indexed) and the full navigation-history list. Returns: A confirmation message describing the operation performed for @@ -449,20 +463,20 @@ def find_elements( dictionaries. It does not return live SeleniumBase element objects. Args: - selector: CSS selector, or a SeleniumBase selector that can match - visible text. Examples include "button", ".login-link", or - 'a:contains("Sign in")'. + selector: CSS Selector or XPath selector. + timeout: Maximum number of seconds to wait for matching elements - to be found. (Defaults to 0.5 seconds.) + to be found. + include_html: If True, include each matching element's outer HTML. - If False, return only tag name and text. (Defaults to False.) + 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. - If there are no matching elements, returns an empty dictionary. + If there's an error, returns a string with error details. Tool selection: - Need structured information about matching elements -> @@ -479,10 +493,7 @@ def find_elements( appropriate interaction tool. """ sb = _get_sb() - try: - elements = sb.find_all(selector, timeout=timeout) - except Exception: - return {} + elements = sb.find_elements(selector, timeout=timeout) if include_html: return { @@ -591,6 +602,7 @@ def get_attributes( Args: selector: CSS selector or SeleniumBase text-matching selector for the target element. + attribute: Specific HTML attribute to retrieve. When omitted, return all HTML attributes of the element as a dictionary. @@ -606,7 +618,7 @@ def get_attributes( - Need visible text or HTML content -> use 'get_content'. - Need to check element presence/visibility -> use 'check_condition'. - This is a read-only operation and does not modify the element. + This is a read-only operation. """ sb = _get_sb() @@ -622,7 +634,7 @@ def check_condition( check: Literal["present", "visible"] = "visible", selector: str = "body", text: str | None = None, -) -> Any: +) -> bool | str: """Check the current state of an element or text without waiting for the condition to become true. @@ -636,13 +648,10 @@ def check_condition( The element state to inspect when text is not provided: - "present": Return True when at least one matching element exists. - "visible": Return True when the matching element is visible. - Defaults to "visible". `check` is ignored when `text` is provided. selector: - CSS selector or SeleniumBase selector identifying the element to - inspect. Defaults to "body". When `text` is provided, this also - identifies the element whose visible text is checked. + CSS selector or SeleniumBase selector identifying the element. text: Optional text to check for visibility within `selector`. When @@ -654,16 +663,14 @@ def check_condition( Returns: True or False indicating whether the requested condition is currently satisfied. Missing elements return False rather than raising an - exception. + exception. If there's an error, returns a string with error details. Tool selection: - Immediate boolean observation -> use check_condition. - - Wait for an element or text condition to become true/false -> - use wait_for. - - Verify an expected condition and fail when it is not met -> - use assert_condition. - - Need the number or details of matching elements -> use find_elements. - - Need to read the actual page or element content -> use get_content. + - Wait for a state/content transition -> use wait_for. + - Verify an expected condition -> use assert_condition. + - Need element details of matching elements -> use find_elements. + - Need to read page or element content -> use get_content. Notes: This tool does not intentionally wait for elements or text to appear. @@ -683,7 +690,9 @@ def check_condition( if check == "visible": return sb.is_element_visible(selector) - return f"Error: unknown check '{check}'. Use 'present' or 'visible'." + return ( + f"Error: Unknown check {check!r}. Use 'present' or 'visible'." + ) # --------------------------------------------------------------------------- @@ -703,32 +712,36 @@ def click( ) -> str: """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")'. + The selector may be a CSS selector or an XPath selector. + (A SeleniumBase text-matching selector such as 'a:contains("Sign in")' + can only be used only when NOT setting `all_matches`.) Args: 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. + + all_matches: Click every currently visible matching element, in the + order that they appear on the page. Ignored when nth is provided. + + only_if_visible: 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. + + timeout: Seconds to wait for a basic click. + scroll: Scroll the target into view before clicking. Tool selection: - Click one matching element -> basic click. - - Click a specific matching occurrence -> set nth. + - 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. + - Click an element nested in another element -> set `parent_selector`. """ sb = _get_sb() @@ -756,7 +769,7 @@ def click( @mcp.tool() @handle_sb_errors -def hover_with_action( +def hover_action( selector1: str, selector2: str | None = None, action: Literal[ @@ -765,7 +778,7 @@ def hover_with_action( "drag_and_drop", ] = "none", ) -> str: - """Hover over an element, optionally click another element, or drag-&-drop. + """Hover over an element, optionally click another, or drag-and-drop. Use this tool for hover interactions, hover-triggered menus, and drag-and-drop operations. @@ -773,23 +786,17 @@ def hover_with_action( Args: selector1: The primary element selector. - For action="none", this is the element to hover over. - For action="click", this is the element to hover over before clicking selector2. - For action="drag_and_drop", this is the draggable source element. selector2: The secondary element selector. - Required for action="click", where it identifies the element revealed or targeted after hovering selector1. - Required for action="drag_and_drop", where it identifies the destination/drop target. - Not used for action="none". action: @@ -798,7 +805,7 @@ def hover_with_action( - "drag_and_drop": Drag selector1 and drop it onto selector2. Returns: - A confirmation describing the performed operation. + A confirmation message describing the performed operation. Tool selection: - Simple hover -> action="none". @@ -857,7 +864,9 @@ def type_text( Args: selector: CSS selector or SeleniumBase selector identifying the input, textarea, or contenteditable element. + text: Text to enter or set. Not used when mode="clear_only". + mode: - "fill_input": Clear the field and then type text normally. - "append": Keep the existing value and add text as keystrokes. @@ -867,6 +876,7 @@ def type_text( key events. It can also be used to handle input sliders, e.g. 'input[type="range"]'. - "clear_only": Empty the text field; text is ignored. + timeout: Maximum seconds to wait for the target element. Tool selection: @@ -909,8 +919,10 @@ def select_option( Args: dropdown_selector: CSS selector identifying the