diff --git a/README.md b/README.md index 8eb447c..f13b3b4 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ claude mcp add seleniumbase-sb -- uv run seleniumbase-sb | `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 | +| `save_screenshot(filename)` | Save a screenshot | | `execute_script(script)` | Run a JS script | ## Design notes / things to adapt for your use case diff --git a/cdp_server.py b/cdp_server.py index 425592c..fe488bd 100644 --- a/cdp_server.py +++ b/cdp_server.py @@ -588,10 +588,7 @@ def get_content( output_format: Literal["text", "html", "urls"] = "text", timeout: float = 5, ) -> str | list[str]: - """Read visible text, HTML, or discovered URLs from the selected element. - - Use this tool when you need to get actual page content or URL information - rather than page metadata. + """Get visible text, HTML, or discovered URLs from the selected element. Args: selector: CSS selector or SeleniumBase-supported XPath selector. @@ -670,9 +667,6 @@ def get_attributes( - Need to check element presence/visibility -> use 'check_if_condition'. - This is a read-only operation: It finds elements to get the requested data, - but it does not make any modifications to those elements. - If there's no matching element found within the timeout, then @handle_sb_errors returns details from the exception raised. """ @@ -1625,10 +1619,18 @@ def scroll_page( # Windows & tabs # --------------------------------------------------------------------------- -@mcp.tool(title="Manage Window") -# Annotations intentionally omitted: 'get_rect' is a pure read while -# 'set_rect'/'maximize'/'minimize' modify window state -- no single -# read_only_hint value would be accurate for the whole tool. +@mcp.tool( + title="Manage Window", + annotations=ToolAnnotations( + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, + ), +) +# The 'read_only_hint' annotation has been intentionally omitted: +# 'get_rect' is a pure read while +# 'set_rect'/'maximize'/'minimize' modify window state. +# There's no single 'read_only_hint' value that's accurate for the whole tool. @handle_sb_errors def manage_window( action: Literal[ @@ -1808,10 +1810,8 @@ def solve_captcha() -> str: """Attempt a SeleniumBase CDP-based CAPTCHA interaction, such as clicking a CAPTCHA checkbox, or performing a drag/drop action on a slider CAPTCHA. - This tool attempts to interact with CAPTCHA controls such as Cloudflare - Turnstile, reCAPTCHA, hCaptcha, DataDome Slider, or FriendlyCaptcha via - the Chrome DevTools Protocol (CDP), which is usually stealthier than - JavaScript because CDP actions can avoid triggering `isTrusted: false`. + Supported CAPTCHAs include: Cloudflare Turnstile, reCAPTCHA, hCaptcha, + DataDome Slider, and FriendlyCaptcha. This tool automatically detects the coordinates of CAPTCHA checkboxes for determining the correct location to perform the click. If no CAPTCHA @@ -1830,8 +1830,13 @@ def solve_captcha() -> str: or 'manage_cookies' to inspect resulting page/session state. Returns: - A message confirming that the CAPTCHA interaction was attempted. + A confirmation message of the CAPTCHA interaction. The message is the same for both successful and failed attempts. + + Notes: + Clicking with the Chrome DevTools Protocol (CDP) is generally + stealthier than clicking with JavaScript because CDP actions + can avoid triggering `isTrusted: false`. """ sb = _get_sb() sb.solve_captcha() diff --git a/driver_server.py b/driver_server.py index df757d7..40030e2 100644 --- a/driver_server.py +++ b/driver_server.py @@ -15,6 +15,7 @@ from typing import Any, Literal from mcp.server import MCPServer from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import ToolAnnotations from seleniumbase import Driver mcp = MCPServer("seleniumbase-driver") @@ -48,7 +49,17 @@ def wrapper(*args, **kwargs): # Session lifecycle # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Start Browser", + annotations=ToolAnnotations( + # Single, consistent behavior: launches (or no-ops if already + # running) a persistent browser session. + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, # No-ops with the same message if already up. + open_world_hint=True, # Launches a real browser onto the open web. + ), +) @handle_sb_errors def start_browser( browser: Literal["chrome", "edge", "firefox", "chromium"] = "chrome", @@ -121,7 +132,14 @@ def start_browser( ) -@mcp.tool() +@mcp.tool( + title="Close Browser", + annotations=ToolAnnotations( + read_only_hint=False, + idempotent_hint=True, + open_world_hint=False, + ), +) @handle_sb_errors def close_browser() -> str: """Close the browser and end the session.""" @@ -137,7 +155,15 @@ def close_browser() -> str: # Navigation # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Open URL", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def open_url(url: str) -> str: """Navigate to the given URL in the web browser. @@ -151,7 +177,15 @@ def open_url(url: str) -> str: return f"Navigated to {url}" -@mcp.tool() +@mcp.tool( + title="Go Back", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def go_back() -> str: """Go back one page in browser history. @@ -160,7 +194,15 @@ def go_back() -> str: return "Navigated back." -@mcp.tool() +@mcp.tool( + title="Go Forward", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def go_forward() -> str: """Go forward one page in browser history. @@ -169,7 +211,15 @@ def go_forward() -> str: return "Navigated forward." -@mcp.tool() +@mcp.tool( + title="Refresh Page", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def refresh_page() -> str: """Refresh the current page. @@ -178,14 +228,30 @@ def refresh_page() -> str: return "Page refreshed." -@mcp.tool() +@mcp.tool( + title="Get Current URL", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def get_current_url() -> str: """Get the URL of the current page.""" return _get_driver().get_current_url() -@mcp.tool() +@mcp.tool( + title="Get Title", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def get_title() -> str: """Get the title of the current page.""" @@ -196,14 +262,30 @@ def get_title() -> str: # Reading page content # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Get Page Source", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @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() +@mcp.tool( + title="Get Text", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def get_text(selector: str) -> str: """Get the visible text of an element matched by a CSS selector. @@ -212,14 +294,30 @@ def get_text(selector: str) -> str: return _get_driver().get_text(selector) -@mcp.tool() +@mcp.tool( + title="Find Elements Count", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @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() +@mcp.tool( + title="Is Element Visible", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def is_element_visible(selector: str) -> bool: """Check whether an element matched by a CSS selector is visible.""" @@ -230,9 +328,17 @@ def is_element_visible(selector: str) -> bool: # Interacting with elements # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Click Element", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors -def click_element(selector: str, timeout: float = 7) -> str: +def click_element(selector: str, timeout: float = 5) -> str: """Click an element matched by the given selector. Raises an exception if the element isn't found within the timeout.""" d = _get_driver() @@ -240,13 +346,21 @@ def click_element(selector: str, timeout: float = 7) -> str: return f"Clicked {selector}" -@mcp.tool() +@mcp.tool( + title="Type Text", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def type_text( selector: str, text: str, clear_first: bool = True, - timeout: float = 7, + timeout: float = 5, ) -> str: """Type text into an input field / textarea. Raises an exception if the element isn't found within the timeout. @@ -265,7 +379,15 @@ def type_text( return f"Typed into {selector}" -@mcp.tool() +@mcp.tool( + title="Select Option By Text", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def select_option_by_text(dropdown_selector: str, option: str) -> str: """Select a dropdown option by its value attribute. @@ -285,7 +415,15 @@ def select_option_by_value(dropdown_selector: str, option: str) -> str: return f"Selected value '{option}' in {dropdown_selector}" -@mcp.tool() +@mcp.tool( + title="Select Option By Index", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def select_option_by_index( dropdown_selector: str, @@ -298,7 +436,15 @@ def select_option_by_index( return f"Selected index '{option}' in {dropdown_selector}" -@mcp.tool() +@mcp.tool( + title="Wait For Element", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def wait_for_element(selector: str, timeout: float = 10) -> str: """Wait until an element matched by a CSS selector appears. @@ -311,7 +457,15 @@ def wait_for_element(selector: str, timeout: float = 10) -> str: # Frames # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Switch To Frame", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def switch_to_frame(selector: str) -> str: """Switch driver focus into an iframe matched by a CSS selector.""" @@ -319,7 +473,15 @@ def switch_to_frame(selector: str) -> str: return f"Switched into frame {selector}" -@mcp.tool() +@mcp.tool( + title="Switch To Default Content", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def switch_to_default_content() -> str: """Switch driver focus back out to the main page (out of any iframe).""" @@ -331,12 +493,20 @@ def switch_to_default_content() -> str: # Assertions / verification # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Assert Text", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def assert_text( text: str, selector: str = "", - timeout: float = 7, + timeout: float = 5, ) -> str: """Assert that text is visible on the page, or within a specific element. Raises an error (returned as a tool error to the client) if not found.""" @@ -349,11 +519,19 @@ def assert_text( return f"Confirmed text '{text}' is visible." -@mcp.tool() +@mcp.tool( + title="Assert Element", + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), +) @handle_sb_errors def assert_element( selector: str, - timeout: float = 7, + timeout: float = 5, ) -> str: """Assert that an element is visible on the page. Raises an error (returned as a tool error to the client) if not found.""" @@ -366,7 +544,15 @@ def assert_element( # UC Mode / CDP Mode stealth helpers (require start_browser(uc=True)) # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Activate CDP Mode", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def activate_cdp_mode(url: str | None = None) -> str: """Switch the current browser session into CDP Mode, which adds stealth @@ -376,7 +562,15 @@ def activate_cdp_mode(url: str | None = None) -> str: return f"CDP Mode activated (url={url!r})" -@mcp.tool() +@mcp.tool( + title="Solve CAPTCHA", + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def solve_captcha() -> str: """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" @@ -388,15 +582,29 @@ def solve_captcha() -> str: # Misc # --------------------------------------------------------------------------- -@mcp.tool() +@mcp.tool( + title="Save Screenshot", + annotations=ToolAnnotations( + read_only_hint=False, + idempotent_hint=False, + open_world_hint=False, + ), +) @handle_sb_errors -def screenshot(filename: str = "screenshot.png") -> str: +def save_screenshot(filename: str = "screenshot.png") -> str: """Take a screenshot of the current page and save it to disk.""" _get_driver().save_screenshot(filename) return f"Screenshot saved to {filename}" -@mcp.tool() +@mcp.tool( + title="Execute Script", + annotations=ToolAnnotations( + read_only_hint=False, + idempotent_hint=False, + open_world_hint=True, + ), +) @handle_sb_errors def execute_script(script: str) -> Any: """Execute JavaScript in the page context and return the result. diff --git a/pyproject.toml b/pyproject.toml index f57f907..0b36be7 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.14", # Needed for `mcp dev` + "uv>=0.12.15", # Needed for `mcp dev` ] [build-system] diff --git a/requirements.txt b/requirements.txt index 5121087..69b3395 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -seleniumbase[mcp]>=4.54.6 +seleniumbase[mcp]>=4.54.7 mcp[cli]>=2.2.0,<3.0.0 diff --git a/sb_server.py b/sb_server.py index 32468bd..5b6aea1 100644 --- a/sb_server.py +++ b/sb_server.py @@ -342,7 +342,7 @@ def is_selected(selector: str) -> bool | str: @mcp.tool() @handle_sb_errors -def click_element(selector: str, timeout: float = 7) -> str: +def click_element(selector: str, timeout: float = 5) -> str: """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) @@ -388,7 +388,7 @@ def click_link(link_text: str) -> str: @mcp.tool() @handle_sb_errors -def double_click(selector: str, timeout: float = 7) -> str: +def double_click(selector: str, timeout: float = 5) -> str: """Double-click an element. Raises an exception if the element isn't found within the default timeout. """ @@ -412,7 +412,7 @@ def type_text( selector: str, text: str, clear_first: bool = True, - timeout: float = 7, + timeout: float = 5, ) -> str: """Type text into an input field / textarea. Raises an exception if the element isn't found within the timeout. @@ -433,7 +433,7 @@ def type_text( @mcp.tool() @handle_sb_errors -def set_value(selector: str, text: str, timeout: float = 7) -> str: +def set_value(selector: str, text: str, timeout: float = 5) -> str: """Set an input's value directly (e.g. for sliders, fast form fills). Raises an exception if the element isn't found within the timeout. """ @@ -443,7 +443,7 @@ def set_value(selector: str, text: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def clear_input(selector: str, timeout: float = 7) -> str: +def clear_input(selector: str, timeout: float = 5) -> str: """Clear an input field. Raises an exception if the element isn't found within the timeout. """ @@ -453,7 +453,7 @@ def clear_input(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def submit(selector: str, timeout: float = 7) -> str: +def submit(selector: str, timeout: float = 5) -> str: """Submit a form via a selector inside it. Raises an exception if the element isn't found within the timeout. """ @@ -466,7 +466,7 @@ def submit(selector: str, timeout: float = 7) -> str: def select_option_by_text( dropdown_selector: str, option: str, - timeout: float = 7, + timeout: float = 5, ) -> str: """Select a dropdown option by its value attribute. Raises an exception if the element or option aren't found @@ -498,7 +498,7 @@ def select_option_by_value( def select_option_by_index( dropdown_selector: str, option: int, - timeout: float = 7, + timeout: float = 5, ) -> str: """Select a element to upload a local file.""" _get_sb().choose_file(selector, file_path, timeout=timeout) return f"Set file input {selector} to {file_path}" @@ -556,7 +556,7 @@ def choose_file(selector: str, file_path: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def wait_for_element(selector: str, timeout: float = 7) -> str: +def wait_for_element(selector: str, timeout: float = 5) -> str: """Wait until an element is visible on the page.""" _get_sb().wait_for_element(selector, timeout=timeout) return f"Element {selector} is visible." @@ -564,7 +564,7 @@ def wait_for_element(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def wait_for_element_present(selector: str, timeout: float = 7) -> str: +def wait_for_element_present(selector: str, timeout: float = 5) -> str: """Wait until an element is present in the DOM (may not be visible).""" _get_sb().wait_for_element_present(selector, timeout=timeout) return f"Element {selector} is present." @@ -572,7 +572,7 @@ def wait_for_element_present(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def wait_for_element_not_visible(selector: str, timeout: float = 7) -> str: +def wait_for_element_not_visible(selector: str, timeout: float = 5) -> str: """Wait until an element is no longer visible.""" _get_sb().wait_for_element_not_visible(selector, timeout=timeout) return f"Element {selector} is no longer visible." @@ -580,7 +580,7 @@ def wait_for_element_not_visible(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def wait_for_element_absent(selector: str, timeout: float = 7) -> str: +def wait_for_element_absent(selector: str, timeout: float = 5) -> str: """Wait until an element is removed from the DOM.""" _get_sb().wait_for_element_absent(selector, timeout=timeout) return f"Element {selector} is now absent." @@ -589,7 +589,7 @@ def wait_for_element_absent(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors def wait_for_text( - text: str, selector: str = "html", timeout: float = 7 + text: str, selector: str = "html", timeout: float = 5 ) -> str: """Wait until specific text appears within an element.""" _get_sb().wait_for_text(text, selector, timeout=timeout) @@ -602,7 +602,7 @@ def wait_for_text( @mcp.tool() @handle_sb_errors -def assert_element(selector: str, timeout: float = 7) -> str: +def assert_element(selector: str, timeout: float = 5) -> str: """Assert an element is visible.""" _get_sb().assert_element(selector, timeout=timeout) return f"Confirmed {selector} is visible." @@ -610,7 +610,7 @@ def assert_element(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def assert_element_present(selector: str, timeout: float = 7) -> str: +def assert_element_present(selector: str, timeout: float = 5) -> str: """Assert an element is present in the DOM (may not be visible).""" _get_sb().assert_element_present(selector, timeout=timeout) return f"Confirmed {selector} is present." @@ -618,7 +618,7 @@ def assert_element_present(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def assert_element_not_visible(selector: str, timeout: float = 7) -> str: +def assert_element_not_visible(selector: str, timeout: float = 5) -> str: """Assert an element is not visible.""" _get_sb().assert_element_not_visible(selector, timeout=timeout) return f"Confirmed {selector} is not visible." @@ -626,7 +626,7 @@ def assert_element_not_visible(selector: str, timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors -def assert_text(text: str, selector: str = "html", timeout: float = 7) -> str: +def assert_text(text: str, selector: str = "html", timeout: float = 5) -> str: """Assert text is present within an element.""" _get_sb().assert_text(text, selector, timeout=timeout) return f"Confirmed '{text}' is present in {selector}." @@ -635,7 +635,7 @@ def assert_text(text: str, selector: str = "html", timeout: float = 7) -> str: @mcp.tool() @handle_sb_errors def assert_exact_text( - text: str, selector: str = "html", timeout: float = 7 + text: str, selector: str = "html", timeout: float = 5 ) -> str: """Assert an element's text matches exactly.""" _get_sb().assert_exact_text(text, selector, timeout=timeout) @@ -960,7 +960,7 @@ def execute_script(script: str) -> Any: @mcp.tool() @handle_sb_errors -def highlight(selector: str, loops: int = 4, timeout: float = 7) -> str: +def highlight(selector: str, loops: int = 4, timeout: float = 5) -> str: """Briefly highlight an element with a colored animation — useful for narrating actions on a visible/headed browser.""" _get_sb().highlight(selector, loops=loops, timeout=timeout) diff --git a/setup.py b/setup.py index 19893f6..da34bd8 100755 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( name="seleniumbase-mcp", - version="1.3.6", + version="1.3.7", description="MCP servers exposing SeleniumBase as tools for MCP clients.", long_description=long_description, long_description_content_type="text/markdown", @@ -140,7 +140,7 @@ ], python_requires=">=3.10", install_requires=[ - "seleniumbase[mcp]>=4.54.6", + "seleniumbase[mcp]>=4.54.7", "mcp[cli]>=2.2.0,<3.0.0", ], extras_require={ @@ -149,10 +149,10 @@ "twine>=7.0.0", ], "uv": [ - "uv>=0.12.14", + "uv>=0.12.15", ], "dev": [ - "uv>=0.12.14", + "uv>=0.12.15", ], }, entry_points={