Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,19 @@ in the loop at all. Reference:

### Tool groups

| Group | Examples |
|---|---|
| Session | `start_browser(url, headless, incognito, guest, proxy, ad_block)`, `close_browser` |
| Navigation | `navigate`, `reload_page`, `go_back`/`go_forward`, `get_current_url`, `get_title` |
| Finding & reading | `find_element_info`, `find_all_info`, `get_text`, `get_html_source`, `get_element_attribute(s)`, `is_element_present/visible` |
| Interacting | `click`, `click_if_visible`, `click_visible_elements`, `type_text`, `send_keys`, `set_value`, `select_option_by_text/value/index`, `nested_click` |
| Waiting | `wait_for_element_present`, `wait_for_element_visible/not_visible/absent`, `wait_for_text` |
| Assertions | `assert_element`, `assert_text`, `assert_exact_text`, `assert_title`, `assert_url(_contains)` |
| Cookies & storage | `get_all_cookies`, `save_cookies`/`load_cookies`, `get/set_local_storage_item`, `get/set_session_storage_item` |
| Scrolling | `scroll_into_view`, `scroll_to_top/bottom`, `scroll_up/down` |
| Tabs & windows | `open_new_tab`, `switch_to_tab`/`switch_to_newest_tab`, `close_active_tab`, `maximize`/`minimize`, `get/set_window_rect` |
| Captcha | `solve_captcha` |
| Output | `save_screenshot`, `save_page_source`, `save_as_pdf`, `evaluate` |
| Group | Tool(s) |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session | `start_browser(url, headless, use_chromium, browser_executable_path, incognito, guest, ad_block, proxy)`, `close_browser` |
| Navigation | `navigate`, `navigate_history(action: back/forward/reload)`, `get_page_info` (running status, url, title, origin, user agent, history in one call) |
| Finding & reading | `find_elements(selector, timeout, include_html)`, `get_content(selector, output_format: text/html/urls, include_shadow_dom)`, `get_attributes`, `check_state(check: present/visible/count/text_visible)` |
| Interacting | `click(selector, nth, all_matches, only_if_visible, parent_selector, timeout, scroll)`, `hover_with_action(selector1, selector2, action: none/click/drag_and_drop)`, `fill_input(mode: type/append/set_value/fast_type/clear)`, `select_option(by: text/value/index)`, `focus_on(action: scroll_to_element/focus/highlight)` |
| Waiting | `wait_for(state: present/visible/not_visible/absent, text)` |
| Assertions | `assert_that(check: element_present/element_visible/text/title/url/url_contains)` |
| Cookies & storage | `manage_cookies(action: get_all/clear/save/load)`, `manage_storage(storage: local/session, action: get/set)` |
| Scrolling | `scroll(direction: up/down/top/bottom, amount)` |
| Windows & tabs | `manage_window(action: get_rect/set_rect/maximize/minimize)`, `manage_tabs(action: list/open/switch/switch_newest/close_active)` |
| Captcha | `solve_captcha` |
| Output & misc | `save_output(format: screenshot/html/pdf)`, `run_javascript`, `wait_seconds` |

### CDP-specific design notes

Expand Down
146 changes: 86 additions & 60 deletions cdp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,8 @@
- Use wait_for when the agent needs to wait for a condition to become true.
- Use assert_that when the agent needs to verify an expected condition and
treat failure as an assertion error.
- Use click/fill_input/select_option/hover/act_on_element for interactions.

Note on elements: CDP-mode element objects (from find_element/find_all) are
live handles with their own methods (.click(), .get_html(), ...) that can't
cross the MCP boundary as stateful objects. Tools here resolve elements
immediately to plain dicts (tag, text, html) rather than returning handles.
To act on one of several matches, use click(selector, nth=...) rather than
find + click.
- Use click/fill_input/select_option/hover_with_action/focus_on for
interactions and element positioning.
"""
from __future__ import annotations
import atexit
Expand Down Expand Up @@ -708,54 +702,84 @@ def click(

@mcp.tool()
@handle_sb_errors
def hover(
selector: str,
then_click_selector: str | None = None,
def hover_with_action(
selector1: str,
selector2: str | None = None,
action: Literal[
"none",
"click",
"drag_and_drop",
] = "none",
) -> str:
"""Hover over an element, optionally clicking an element revealed by hover.
"""Hover over an element, optionally click another element, or drag-&-drop.

Use this for menus, dropdowns, tooltips, or other interfaces where an
element must first be hovered before its target becomes available.
Use this tool for hover interactions, hover-triggered menus, and
drag-and-drop operations.

Args:
selector: Element to hover over.
then_click_selector: Optional element to click after the hover.
Useful for a submenu item or dropdown option revealed by hover.
selector1:
The primary element selector.

Returns:
A confirmation describing the hover/click operation.
"""
sb = _get_sb()
For action="none", this is the element to hover over.

if then_click_selector:
sb.hover_and_click(selector, then_click_selector)
return f"Hovered {selector} and clicked {then_click_selector}"
For action="click", this is the element to hover over before
clicking selector2.

sb.hover_element(selector)
return f"Hovered {selector}"
For action="drag_and_drop", this is the draggable source element.

selector2:
The secondary element selector.

@mcp.tool()
@handle_sb_errors
def drag_and_drop(
source_selector: str,
target_selector: str,
) -> str:
"""Drag a draggable element and drop it onto another element.
Required for action="click", where it identifies the element
revealed or targeted after hovering selector1.

Drag-and-drop is performed through SeleniumBase's CDP browser controls,
simulating the pointer and mouse events expected by web applications.
Required for action="drag_and_drop", where it identifies the
destination/drop target.

Args:
source_selector: CSS selector identifying the draggable source.
target_selector: CSS selector identifying the drop target.
Not used for action="none".

action:
- "none": Hover over selector1 only.
- "click": Hover over selector1, then click selector2.
- "drag_and_drop": Drag selector1 and drop it onto selector2.

Returns:
A confirmation describing the performed operation.

Tool selection:
- Simple hover -> action="none".
- Hover over one element and then click another -> action="click".
- Drag one element onto another -> action="drag_and_drop".

Notes:
For action="click", selector1 is the hover target and selector2 is
the click target.

The simulated interaction includes events such as pointerdown,
mousedown, dragstart, dragenter, dragover, drop, dragend, mouseup, and
pointerup.
For action="drag_and_drop", selector1 is the source and selector2
is the destination.
"""
_get_sb().drag_and_drop(source_selector, target_selector)
return f"Dragged {source_selector} onto {target_selector}"
sb = _get_sb()

if action == "none":
sb.hover_element(selector1)
return f"Hovered {selector1}"

if action == "click":
if selector2 is None:
return "Error: action='click' requires selector2."
sb.hover_and_click(selector1, selector2)
return f"Hovered {selector1} and clicked {selector2}"

if action == "drag_and_drop":
if selector2 is None:
return "Error: action='drag_and_drop' requires selector2."
sb.drag_and_drop(selector1, selector2)
return f"Dragged {selector1} onto {selector2}"

return (
f"Error: unknown action '{action}'. "
"Use 'none', 'click', or 'drag_and_drop'."
)


@mcp.tool()
Expand Down Expand Up @@ -860,50 +884,52 @@ def select_option(

@mcp.tool()
@handle_sb_errors
def act_on_element(
def focus_on(
selector: str,
action: Literal[
"scroll_to_element",
"focus",
"highlight",
"scroll_into_view",
] = "focus",
] = "scroll_to_element",
) -> str:
"""Perform a non-click positioning or debugging action on an element.
"""Scroll to, focus, or highlight an element.

Use this tool when an element needs to be focused, highlighted for human
observation/debugging, or scrolled into the viewport.
Use this tool when an element needs to be brought into view, focused for
keyboard interaction, or highlighted for debugging/demonstration.

This tool does NOT click, type into, select from, hover over, or otherwise
activate the element.

Args:
selector: CSS selector or SeleniumBase selector identifying the target.

action:
- "scroll_to_element": Scroll the page until the element is in
the current viewport. This is the default action.
- "focus": Move keyboard focus to the element.
- "highlight": Temporarily highlight the element for debugging or
demonstration. This can affect timing and may reduce stealth.
- "scroll_into_view": Scroll the page until the element is in the
current viewport.

Tool selection:
- Bring an element into view -> use focus_on with the default action.
- Focus an element -> use focus_on(action="focus").
- Highlight element for debugging -> use focus_on(action="highlight").
- Click -> use click.
- Type into a form control -> use fill_input.
- Hover -> use hover.
- Focus, highlight, or scroll without activating -> use
act_on_element.
- Hover -> use hover_with_action.
"""
sb = _get_sb()

if action == "focus":
if action == "scroll_to_element":
sb.scroll_into_view(selector)
elif action == "focus":
sb.find_element(selector).focus()
elif action == "highlight":
sb.highlight(selector)
elif action == "scroll_into_view":
sb.scroll_into_view(selector)
else:
return (
f"Error: unknown action '{action}'. "
"Use 'focus', 'highlight', or 'scroll_into_view'."
"Use 'scroll_to_element', 'focus', or 'highlight'."
)

return f"{action} done for {selector}"
Expand Down Expand Up @@ -1221,8 +1247,8 @@ def scroll(
up/down scrolling. For example, amount=25 scrolls approximately
one quarter of the viewport height.

Use act_on_element(action="scroll_into_view") when the goal is to reveal
a specific element rather than scroll the page by a relative amount.
Use focus_on(action="scroll_to_element") when the goal is to reveal a
specific element rather than scroll the page by a relative amount.
"""
sb = _get_sb()

Expand Down
91 changes: 66 additions & 25 deletions driver_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import atexit
import sys
from functools import wraps
from typing import Any
from typing import Any, Literal
from mcp.server import MCPServer
from seleniumbase import Driver

Expand Down Expand Up @@ -48,41 +48,79 @@ def wrapper(*args, **kwargs):
@mcp.tool()
@handle_sb_errors
def start_browser(
browser: str = "chrome",
headless: bool = False,
browser: Literal["chrome", "edge", "firefox", "chromium"] = "chrome",
headless: bool | None = None,
uc: bool = True,
incognito: bool = False,
guest_mode: bool = False,
proxy: str | None = None,
ad_block: bool = False,
proxy: str | None = None,
) -> str:
"""Start a new browser session. Must be called before any other tool.

Args:
headless: Run without a visible window. Set False if you need to
watch the browser or if a site blocks headless clients.
browser: "chrome", "edge", or "firefox".
browser: "chrome", "edge", "firefox", or "chromium".
headless: Controls whether the browser runs without a visible window.
If True, always run headless. If False, always run headed.
If omitted (None), the default depends on the operating system:
Linux defaults to headless because MCP/server environments
commonly do not have a graphical desktop, while Windows and macOS
default to headed so that a visible browser window is available.
Use True or False to explicitly override the OS-specific default
on any operating system.
uc: Undetected-chromedriver mode, useful for sites with bot detection.
incognito: Launch in a private/incognito window.
(The `uc` option is for Chrome/Chromium, only!)
incognito: Launch Chrome/Chromium in incognito mode.
guest: Launch Chrome/Chromium in guest mode.
(Do not combine this with incognito=True.)
ad_block: Enable SeleniumBase's basic ad-blocking functionality.
proxy: Optional proxy server. Examples include
"SERVER:PORT" or "USER:PASS@SERVER:PORT".
"""
global _driver
if _driver is not None:
return (
"A browser session is already running. Call close_browser first."
)
_driver = Driver(
browser=browser,
headless=headless,
uc=uc,
incognito=incognito,
guest_mode=guest_mode,
proxy=proxy,
ad_block=ad_block,
)
return (
f"Started Driver() session with browser={browser}, "
f"headless={headless}, uc={uc}."
)

# OS-specific default:
# - Linux: headless by default for server/container compatibility.
# - Windows/macOS: headed by default for interactive desktop use.
# - Explicit True/False always overrides the OS default.
if headless is None:
headless = sys.platform.startswith("linux")

use_chromium = False
if browser == "chromium":
use_chromium = True
browser = "chrome"

try:
_driver = Driver(
browser=browser,
headless=headless,
use_chromium=use_chromium,
uc=uc,
incognito=incognito,
guest_mode=guest_mode,
ad_block=ad_block,
proxy=proxy,
)
return (
f"Started Driver() session with browser={browser}, "
f"headless={headless}, uc={uc}."
)
except Exception as e:
if _driver is not None:
try:
_driver.quit()
except Exception:
pass
_driver = None

return (
f"Error starting browser: "
f"{e.__class__.__name__} - {str(e).strip()}"
)


@mcp.tool()
Expand Down Expand Up @@ -196,7 +234,7 @@ def is_element_visible(selector: str) -> bool:

@mcp.tool()
@handle_sb_errors
def click(selector: str, timeout: int = 7) -> str:
def click(selector: str, timeout: int | float | None = 7) -> str:
"""Click an element matched by the given selector.
Raises an exception if the element isn't found within the timeout."""
d = _get_driver()
Expand All @@ -207,7 +245,10 @@ def click(selector: str, timeout: int = 7) -> str:
@mcp.tool()
@handle_sb_errors
def type_text(
selector: str, text: str, clear_first: bool = True, timeout: int = 7
selector: str,
text: str,
clear_first: bool = True,
timeout: int | float | None = 7,
) -> str:
"""Type text into an input field / textarea.
Raises an exception if the element isn't found within the timeout.
Expand Down Expand Up @@ -256,7 +297,7 @@ def select_option_by_index(dropdown_selector: str, option: str) -> str:

@mcp.tool()
@handle_sb_errors
def wait_for_element(selector: str, timeout: int = 10) -> str:
def wait_for_element(selector: str, timeout: int | float | None = 10) -> str:
"""Wait until an element matched by a CSS selector appears.
Raises an exception if the element isn't found within the given timeout."""
_get_driver().wait_for_element(selector, timeout=timeout)
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
seleniumbase[mcp]>=4.53.3
seleniumbase[mcp]>=4.53.4
mcp[cli]>=2.1.1,<3.0.0
Loading