From 4014476e288d004a8de38eca582631c9c653fa22 Mon Sep 17 00:00:00 2001 From: Michael Mintz Date: Sun, 23 Aug 2026 18:36:00 -0400 Subject: [PATCH 1/9] Add a SeleniumBase MCP server --- mcp_servers/.mcp.json | 9 + mcp_servers/README.md | 240 ++++++++++++ mcp_servers/__init__.py | 0 mcp_servers/pyproject.toml | 31 ++ mcp_servers/requirements.txt | 7 + mcp_servers/server.py | 706 +++++++++++++++++++++++++++++++++++ mcp_servers/setup.cfg | 4 + 7 files changed, 997 insertions(+) create mode 100644 mcp_servers/.mcp.json create mode 100644 mcp_servers/README.md create mode 100644 mcp_servers/__init__.py create mode 100644 mcp_servers/pyproject.toml create mode 100644 mcp_servers/requirements.txt create mode 100644 mcp_servers/server.py create mode 100644 mcp_servers/setup.cfg 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..8377e8066b8 --- /dev/null +++ b/mcp_servers/README.md @@ -0,0 +1,240 @@ +# mcp_servers + +Exposes [SeleniumBase](https://github.com/seleniumbase/SeleniumBase) browser automation as tools over the [Model Context Protocol](https://modelcontextprotocol.io), so any +MCP client can drive a real browser. + +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, no WebDriver in the loop at all, which makes it +SeleniumBase's stealthiest mode and includes captcha-solving. That's the +mode most people reaching for browser automation from an MCP client +actually want, so it's the one MCP server included in this repo. + +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: one server, +one name, one obvious thing it does. + +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 dropdown option by its value attribute.""" + _get_sb().select_option_by_value(dropdown_selector, value) + return f"Selected value '{value}' in {dropdown_selector}" + + +@mcp.tool() +def select_option_by_index(dropdown_selector: str, index: int) -> str: + """Select a