diff --git a/.dockerignore b/.dockerignore
index b8ee3a6a18f..9ea68210e4a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,8 +10,12 @@
*.log
*.py[cod]
__pycache__/
+downloaded_files/
build/
dist/
help_docs/
+mkdocs_build/
node_modules/
site/
+venv/
+uv.lock
diff --git a/.gitignore b/.gitignore
index af3cf531762..d9c68250bd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,7 @@ pip-selfcheck.json
ipython.1.gz
nosetests.1
.noseids
+uv.lock
# Installer logs
pip-log.txt
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000000..4e6e31ef3cc
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,9 @@
+{
+ "mcpServers": {
+ "seleniumbase-mcp": {
+ "type": "stdio",
+ "command": "seleniumbase-mcp",
+ "args": []
+ }
+ }
+}
diff --git a/README.md b/README.md
index 98ba074315a..e8d7882b7c1 100755
--- a/README.md
+++ b/README.md
@@ -42,8 +42,8 @@
🛂 MasterQA |
🚎 Tours
-🤖 CI/CD |
-🟨 JSMgr |
+🤖 MCP |
+🟨 JS-Mgr |
🌏 Translator |
🎞️ Presenter |
🖼️ Visual |
diff --git a/examples/setup.cfg b/examples/setup.cfg
index 98d235b5ef0..6117fccbba3 100644
--- a/examples/setup.cfg
+++ b/examples/setup.cfg
@@ -1,6 +1,6 @@
[flake8]
# W503 (line break before binary operator) can be ignored.
-exclude=recordings,temp
+exclude=recordings,temp,venv,.venv
ignore=W503
[nosetests]
diff --git a/mcp_servers/.mcp.json b/mcp_servers/.mcp.json
new file mode 100644
index 00000000000..4e6e31ef3cc
--- /dev/null
+++ b/mcp_servers/.mcp.json
@@ -0,0 +1,9 @@
+{
+ "mcpServers": {
+ "seleniumbase-mcp": {
+ "type": "stdio",
+ "command": "seleniumbase-mcp",
+ "args": []
+ }
+ }
+}
diff --git a/mcp_servers/README.md b/mcp_servers/README.md
new file mode 100644
index 00000000000..b80ad7a8ecb
--- /dev/null
+++ b/mcp_servers/README.md
@@ -0,0 +1,238 @@
+# `seleniumbase-mcp`
+
+Exposes [SeleniumBase](https://github.com/seleniumbase/SeleniumBase)'s stealthy browser automation abilities as tools
+over the [Model Context Protocol](https://modelcontextprotocol.io), so any MCP client can drive real browsers.
+
+This folder has a single server, `server.py`, built on SeleniumBase's
+[Pure CDP Mode](https://github.com/seleniumbase/SeleniumBase/blob/master/help_docs/cdp_mode_methods.md) (`seleniumbase.sb_cdp.Chrome`).
+The browser is driven entirely over the Chrome DevTools Protocol,
+and there is no WebDriver in the loop at all, which makes it
+SeleniumBase's stealthiest mode. (CAPTCHA-solving included!)
+Stealth is what most people want from a browser automation MCP client.
+
+Other SeleniumBase automation styles — `Driver()` (WebDriver-based) and
+`SB()` (broadest API, including UC Mode stealth, MFA codes, file
+downloads) — have their own MCP servers too, just not here.
+They live in [seleniumbase/seleniumbase-mcp](https://github.com/seleniumbase/seleniumbase-mcp) as a separate repo,
+to keep this one simple and unambiguous.
+
+Defaults to `headless=False` — the browser window is visible unless you
+pass `headless=True` when starting a session.
+
+## 1. Install
+
+There are two ways to get the `seleniumbase-mcp` command, depending on
+whether you just want to *use* the server or you're developing this repo.
+
+**If you just want to use the server (simplest — no repo clone needed):**
+
+```bash
+pip install "seleniumbase[mcp]"
+```
+
+That's it. This installs `seleniumbase` from PyPI along with the `mcp[cli]`
+extra, and registers a `seleniumbase-mcp` console-script command — the
+same one `[project.scripts]`/`setup.py` wire up either way, just via a
+real released package instead of a local checkout. Skip straight to step 2
+or 3/4 below; you don't need `uv`, and your MCP client config can be as
+simple as `{"command": "seleniumbase-mcp"}` (see step 3's Option A).
+
+**If you're working from a `git clone` of this repo instead of a PyPI install:**
+
+(Requires [uv](https://docs.astral.sh/uv/getting-started/installation/))
+
+This folder lives inside the SeleniumBase repo, so if you've already
+cloned SeleniumBase, just `cd` into this folder and sync:
+
+```bash
+cd mcp_servers
+uv sync
+```
+
+`uv sync` reads `pyproject.toml`, creates a `.venv/` in this folder, and
+installs `mcp[cli]` plus `seleniumbase` — the latter resolved from the
+local SeleniumBase checkout one directory up (in editable mode, via
+`[tool.uv.sources]` in `pyproject.toml`), not from PyPI. It also installs
+this project itself, which registers a `seleniumbase-mcp` console-script
+command via `[project.scripts]`, pointing at `server.py`'s `main()`
+function (`mcp.run(transport="stdio")`). This is what lets `uv run
+seleniumbase-mcp` — no python path, no venv path, no script path — work
+as the MCP client command in steps 3 and 4 below.
+
+
+Pure CDP Mode doesn't use WebDriver, so no `chromedriver` download is
+needed — just a working Chrome/Chromium install.
+
+(No `uv`? `python3 -m venv venv && pip install -r requirements.txt` works
+too — `requirements.txt` installs the local SeleniumBase checkout via
+`-e ..` the same way. Substitute `python server.py` for `uv run
+seleniumbase-mcp` everywhere below, and use absolute `venv/bin/python` +
+script path in your MCP client config instead of the path-free options.)
+
+## 2. Try it standalone (optional sanity check)
+
+```bash
+uv run mcp dev server.py
+```
+
+That opens the MCP Inspector, where you can test commands ("Tools").
+Ctrl+C to exit. The real test is wiring it into a client (next step).
+
+## 3. Connect it to Claude Desktop
+
+Claude Desktop doesn't run from a "project" directory the way Claude Code
+does, so a bare `uv run seleniumbase-mcp` isn't guaranteed to find this
+folder. Two ways to get a stable config:
+
+**Option A — global install (recommended, zero paths anywhere):**
+
+```bash
+uv tool install . # from inside this folder, installs the command globally
+```
+
+This puts `seleniumbase-mcp` on your `PATH` permanently (run `uv tool
+ensurepath` once if it warns that its bin directory isn't on `PATH` yet).
+Then `claude_desktop_config.json` can be just:
+
+```json
+{
+ "mcpServers": {
+ "seleniumbase-mcp": { "command": "seleniumbase-mcp" }
+ }
+}
+```
+
+Note this bakes in the location of the SeleniumBase checkout at install
+time (since `seleniumbase` resolves to `../` via the editable path
+source) — if you move or delete this clone, re-run `uv tool install .`
+from its new location.
+
+**Option B — point `uv` at this folder directly (one absolute path, but no
+venv/interpreter path to track down, and no separate install step):**
+
+```json
+{
+ "mcpServers": {
+ "seleniumbase-mcp": {
+ "command": "uv",
+ "args": ["--directory", "/absolute/path/to/SeleniumBase/mcp_servers", "run", "seleniumbase-mcp"]
+ }
+ }
+}
+```
+
+The location of `claude_desktop_config.json` depends on your system:
+
+- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
+- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
+
+Restart Claude Desktop. You should see a 🔨 tools icon indicating the
+server connected, with tools like `start_browser`, `navigate`, `click`,
+etc. available.
+
+## 4. Connect it to Claude Code
+
+This folder's `.mcp.json` is checked in and ready to use as-is — no path
+editing required, because `uv run seleniumbase-mcp` resolves this project
+from `pyproject.toml` in the current directory:
+
+```json
+{
+ "mcpServers": {
+ "seleniumbase-mcp": {
+ "type": "stdio",
+ "command": "uv",
+ "args": ["run", "seleniumbase-mcp"]
+ }
+ }
+}
+```
+
+**Where does `.mcp.json` go — here, or the SeleniumBase repo root?** It
+stays here, in `mcp_servers/`, not at the repo root, for two reasons:
+
+1. Claude Code auto-loads `.mcp.json` from whatever directory you launch
+ `claude` in. If it lived at the repo root, every contributor running
+ Claude Code anywhere in the (large, mostly unrelated) SeleniumBase
+ monorepo would have this browser-automation server silently
+ registered — not something a random contributor fixing a docs typo is
+ expecting or wants prompted about.
+2. `uv run seleniumbase-mcp` needs `pyproject.toml` to be discoverable
+ from the current directory. That resolves cleanly when `.mcp.json` and
+ `pyproject.toml` sit next to each other in `mcp_servers/`; from the
+ repo root it would need `uv --directory mcp_servers run
+ seleniumbase-mcp` instead (an absolute or relative path baked into the
+ command).
+
+So: run `claude` from inside `mcp_servers/` to get it auto-loaded. If you
+want repo-root convenience too, you can add an opt-in `.mcp.json` at the
+SeleniumBase root using the `--directory mcp_servers` form (same idea as
+Option B above, with a relative path) — just know that doing so makes
+this server available by default in every root-level Claude Code session
+across the whole repo, which the maintainers may or may not want.
+
+If you'd rather register it manually instead of relying on `.mcp.json`:
+
+```bash
+claude mcp add seleniumbase-mcp -- uv run seleniumbase-mcp
+```
+
+(run from inside this folder, for the same reason as above.)
+
+## Tools exposed
+
+| 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`, `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` (run JS) |
+
+## Design notes / things to adapt for your use case
+
+- **Single global session.** The server holds one browser session at a
+ time. This matches how MCP servers are typically launched (one process
+ per client connection) and keeps the tool surface simple. If you need
+ multiple concurrent browser tabs/sessions, you'd extend this to a
+ dict of named sessions and add a `session_id` parameter to each tool.
+
+- **Blocking calls.** SeleniumBase's calls are synchronous and will block
+ the server while a page loads or an element is waited on. For a
+ single-user local tool this is fine; for a multi-client server you'd
+ want to run them in a thread pool via `asyncio.to_thread`.
+
+- **Errors surface as tool errors.** If a selector isn't found or an
+ assertion fails, `sb_cdp.Chrome` raises an exception, which the MCP SDK
+ turns into a tool error the client sees and can react to (e.g. by
+ waiting longer or trying a different selector).
+
+- **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_element_info`/`find_all_info` resolve
+ the element immediately to a plain dict (`tag_name`, `text`, `html`)
+ instead of returning a handle you could call further methods on. If you
+ need to act on one of several matches, use `click_nth_element` (acts by
+ position) rather than "find, then click" as two separate steps.
+
+- **Captcha solving isn't universal.** `solve_captcha` handles supported
+ challenge types (e.g. Cloudflare Turnstile); not a guaranteed bypass
+ for every type of CAPTCHA.
+
+- **Security.** `evaluate` runs arbitrary JS and this server can drive a
+ real browser to real sites — don't expose it over an untrusted network
+ transport; stdio + local trust (the default here) is the safe setup.
+
+## Extending
+
+Adding a tool is just adding a `@mcp.tool()`-decorated function that calls
+the matching `sb_cdp.Chrome` method — SeleniumBase has methods for file
+uploads, drag-and-drop, hovering, network conditions, and more that aren't
+wrapped above yet.
diff --git a/mcp_servers/__init__.py b/mcp_servers/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/mcp_servers/pyproject.toml b/mcp_servers/pyproject.toml
new file mode 100644
index 00000000000..f87c84079e4
--- /dev/null
+++ b/mcp_servers/pyproject.toml
@@ -0,0 +1,31 @@
+[project]
+name = "seleniumbase-mcp"
+version = "1.0.0dev0"
+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.0.0,<3.0.0",
+ "seleniumbase",
+]
+
+# This folder lives inside the SeleniumBase repo itself. The line below
+# tells `uv sync` / `uv run` (run from inside this folder) to resolve
+# "seleniumbase" from the local checkout one directory up, in editable
+# mode, instead of fetching a release from PyPI.
+[tool.uv.sources]
+seleniumbase = { path = "..", editable = true }
+
+[build-system]
+requires = ["setuptools>=70.2.0", "wheel>=0.44.0"]
+build-backend = "setuptools.build_meta"
+
+[project.scripts]
+seleniumbase-mcp = "server:main"
+
+[tool.setuptools]
+py-modules = ["server"]
+
+[tool.setuptools.exclude-package-data]
+"*" = [".venv*", "build*", "dist*", "uv.lock"]
diff --git a/mcp_servers/requirements.txt b/mcp_servers/requirements.txt
new file mode 100644
index 00000000000..c3ef083d1aa
--- /dev/null
+++ b/mcp_servers/requirements.txt
@@ -0,0 +1,7 @@
+mcp[cli]>=2.0.0,<3.0.0
+-e ..
+
+# `-e ..` installs SeleniumBase itself from the repo root (one directory
+# up) in editable mode, since this folder lives inside the SeleniumBase
+# repo — this gives you the local checkout rather than a pinned PyPI
+# release. Run from inside this folder: pip install -r requirements.txt
diff --git a/mcp_servers/server.py b/mcp_servers/server.py
new file mode 100644
index 00000000000..a241e76f1e4
--- /dev/null
+++ b/mcp_servers/server.py
@@ -0,0 +1,706 @@
+#!/usr/bin/env python3
+"""
+SeleniumBase Pure CDP Mode MCP Server
+======================================
+Exposes SeleniumBase's Pure CDP Mode (sync API, `seleniumbase.sb_cdp.Chrome`)
+as MCP tools. Pure CDP Mode drives the browser entirely over the Chrome
+DevTools Protocol (no WebDriver), which is SeleniumBase's stealthiest mode
+and includes captcha-solving support.
+
+Reference:
+github.com/seleniumbase/SeleniumBase/blob/master/help_docs/cdp_mode_methods.md
+
+Model: one persistent `sb_cdp.Chrome` session per server process. Call
+start_browser once, drive it with the other tools, then close_browser.
+
+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 an element
+immediately to a plain dict (tag, text, html) rather than returning a handle.
+If you need to act on a *specific* one of several matching elements, use
+click_nth_element / click_nth_visible_element rather than find + click.
+"""
+
+import atexit
+import sys
+from typing import Any
+from mcp.server import MCPServer
+from seleniumbase import sb_cdp
+
+mcp = MCPServer("seleniumbase-mcp")
+
+_sb: sb_cdp.CDPMethods | None = None
+
+
+def _get_sb() -> sb_cdp.CDPMethods:
+ if _sb is None:
+ raise RuntimeError("No browser session. Call start_browser first.")
+ return _sb
+
+
+# ---------------------------------------------------------------------------
+# Session lifecycle
+# ---------------------------------------------------------------------------
+
+@mcp.tool()
+def start_browser(
+ url: str | None = None,
+ headless: bool = False,
+ incognito: bool = False,
+ guest: bool = False,
+ proxy: str | None = None,
+ ad_block: bool = False,
+) -> 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.
+
+ Args:
+ url: Optional URL to open immediately on launch.
+ headless: Run without a visible window.
+ incognito: Launch in a private/incognito window.
+ guest: Launch in Chrome guest mode.
+ proxy: Proxy string, e.g. "USER:PASS@SERVER:PORT" or "SERVER:PORT".
+ ad_block: Block ads.
+ """
+ global _sb
+ if _sb is not None:
+ return (
+ "A browser session is already running. Call close_browser first."
+ )
+ kwargs: dict[str, Any] = {"headless": headless}
+ if incognito:
+ kwargs["incognito"] = True
+ if guest:
+ kwargs["guest"] = True
+ if proxy:
+ kwargs["proxy"] = proxy
+ if ad_block:
+ kwargs["ad_block"] = True
+ _sb = sb_cdp.Chrome(url, **kwargs)
+ return f"Started Pure CDP Mode browser (url={url!r}, headless={headless})"
+
+
+@mcp.tool()
+def close_browser() -> str:
+ """Close the browser and end the session."""
+ global _sb
+ if _sb is None:
+ return "No browser session was running."
+ _sb.quit()
+ _sb = None
+ return "Browser closed."
+
+
+# ---------------------------------------------------------------------------
+# Navigation
+# ---------------------------------------------------------------------------
+
+@mcp.tool()
+def navigate(url: str) -> str:
+ """Navigate to a URL."""
+ _get_sb().get(url)
+ return f"Navigated to {url}"
+
+
+@mcp.tool()
+def reload_page(ignore_cache: bool = True) -> str:
+ """Reload the current page."""
+ _get_sb().reload(ignore_cache=ignore_cache)
+ return "Page reloaded."
+
+
+@mcp.tool()
+def go_back() -> str:
+ """Go back one page in browser history."""
+ _get_sb().go_back()
+ return "Navigated back."
+
+
+@mcp.tool()
+def go_forward() -> str:
+ """Go forward one page in browser history."""
+ _get_sb().go_forward()
+ return "Navigated forward."
+
+
+@mcp.tool()
+def get_navigation_history() -> Any:
+ """Get the browser's navigation history."""
+ return _get_sb().get_navigation_history()
+
+
+@mcp.tool()
+def get_current_url() -> str:
+ """Get the URL of the current page."""
+ return _get_sb().get_current_url()
+
+
+@mcp.tool()
+def get_title() -> str:
+ """Get the title of the current page."""
+ return _get_sb().get_title()
+
+
+@mcp.tool()
+def get_origin() -> str:
+ """Get the origin (scheme + host) of the current page."""
+ return _get_sb().get_origin()
+
+
+# ---------------------------------------------------------------------------
+# Finding & reading
+# ---------------------------------------------------------------------------
+
+@mcp.tool()
+def find_element_info(
+ selector: str, best_match: bool = False, timeout: int | None = None
+) -> dict:
+ """Find one element and return its tag name, text, and outer HTML.
+
+ Args:
+ selector: CSS selector, or text to search for (CDP mode can match
+ elements by visible text as well as by selector).
+ best_match: When matching by text and multiple elements qualify,
+ pick the one whose text length is closest to the search text.
+ timeout: Seconds to wait for the element to appear.
+ """
+ el = _get_sb().find_element(
+ selector, best_match=best_match, timeout=timeout
+ )
+ return {"tag_name": el.tag_name, "text": el.text, "html": el.get_html()}
+
+
+@mcp.tool()
+def find_all_info(selector: str, timeout: int | None = None) -> list[dict]:
+ """Find all matching elements and return tag name + text for each."""
+ els = _get_sb().find_all(selector, timeout=timeout)
+ return [{"tag_name": e.tag_name, "text": e.text} for e in els]
+
+
+@mcp.tool()
+def get_text(selector: str = "body") -> str:
+ """Get the visible text within an element (default: whole page body)."""
+ return _get_sb().get_text(selector)
+
+
+@mcp.tool()
+def get_html_source(include_shadow_dom: bool = True) -> str:
+ """Get the full HTML source of the current page."""
+ return _get_sb().get_page_source(include_shadow_dom=include_shadow_dom)
+
+
+@mcp.tool()
+def get_element_html(selector: str) -> str:
+ """Get the outer HTML of a specific element."""
+ return _get_sb().get_element_html(selector)
+
+
+@mcp.tool()
+def get_element_attribute(selector: str, attribute: str) -> Any:
+ """Get one attribute's value from an element."""
+ return _get_sb().get_element_attribute(selector, attribute)
+
+
+@mcp.tool()
+def get_element_attributes(selector: str) -> dict:
+ """Get all attributes of an element as a dict."""
+ return _get_sb().get_element_attributes(selector)
+
+
+@mcp.tool()
+def find_elements_count(selector: str, timeout: int | None = None) -> int:
+ """Count how many elements on the page match a selector."""
+ return len(_get_sb().find_elements(selector, timeout=timeout))
+
+
+@mcp.tool()
+def is_element_present(selector: str) -> bool:
+ """Check whether an element matching a selector exists in the DOM."""
+ return _get_sb().is_element_present(selector)
+
+
+@mcp.tool()
+def is_element_visible(selector: str) -> bool:
+ """Check whether an element matching a selector is visible."""
+ return _get_sb().is_element_visible(selector)
+
+
+@mcp.tool()
+def is_text_visible(text: str, selector: str = "body") -> bool:
+ """Check whether specific text is visible within an element."""
+ return _get_sb().is_text_visible(text, selector)
+
+
+@mcp.tool()
+def get_all_urls(absolute: bool = True) -> list[str]:
+ """Get all linked URLs (a, link, img, script, meta) on the page."""
+ return _get_sb().get_all_urls(absolute=absolute)
+
+
+# ---------------------------------------------------------------------------
+# Interacting with elements
+# ---------------------------------------------------------------------------
+
+@mcp.tool()
+def click(
+ selector: str, timeout: int | None = None, scroll: bool = True
+) -> str:
+ """Click an element matched by a CSS selector (or by text, e.g.
+ 'a:contains("Sign in")')."""
+ _get_sb().click(selector, timeout=timeout, scroll=scroll)
+ return f"Clicked {selector}"
+
+
+@mcp.tool()
+def click_if_visible(selector: str, timeout: int = 0) -> str:
+ """Click an element only if it's currently visible; no-op otherwise."""
+ _get_sb().click_if_visible(selector, timeout=timeout)
+ return f"click_if_visible ran for {selector}"
+
+
+@mcp.tool()
+def click_visible_elements(selector: str, limit: int = 0) -> str:
+ """Click every currently-visible element matching a selector, in order
+ (e.g. checking every checkbox on a page). limit=0 means no limit."""
+ _get_sb().click_visible_elements(selector, limit=limit)
+ return f"Clicked visible elements matching {selector}"
+
+
+@mcp.tool()
+def click_nth_element(selector: str, number: int) -> str:
+ """Click the Nth element (1-indexed) matching a selector."""
+ _get_sb().click_nth_element(selector, number)
+ return f"Clicked element #{number} matching {selector}"
+
+
+@mcp.tool()
+def click_link(link_text: str) -> str:
+ """Click a link ( tag) by its visible text."""
+ _get_sb().click_link(link_text)
+ return f"Clicked link with text '{link_text}'"
+
+
+@mcp.tool()
+def type_text(selector: str, text: str, timeout: int | None = None) -> str:
+ """Clear a field and type text into it."""
+ _get_sb().type(selector, text, timeout=timeout)
+ return f"Typed into {selector}"
+
+
+@mcp.tool()
+def send_keys(selector: str, text: str, timeout: int | None = None) -> str:
+ """Send keystrokes to an element without clearing it first."""
+ _get_sb().send_keys(selector, text, timeout=timeout)
+ return f"Sent keys to {selector}"
+
+
+@mcp.tool()
+def set_value(selector: str, text: str, timeout: int | None = None) -> str:
+ """Set an input's value directly (e.g. for sliders, fast form fills)."""
+ _get_sb().set_value(selector, text, timeout=timeout)
+ return f"Set value of {selector}"
+
+
+@mcp.tool()
+def clear_input(selector: str, timeout: int | None = None) -> str:
+ """Clear an input field."""
+ _get_sb().clear_input(selector, timeout=timeout)
+ return f"Cleared {selector}"
+
+
+@mcp.tool()
+def submit(selector: str) -> str:
+ """Submit a form via a selector inside it."""
+ _get_sb().submit(selector)
+ return f"Submitted form via {selector}"
+
+
+@mcp.tool()
+def select_option_by_text(dropdown_selector: str, option_text: str) -> str:
+ """Select a