diff --git a/mcp_servers/README.md b/mcp_servers/README.md index 5eebf8baccc..075b3deaf61 100644 --- a/mcp_servers/README.md +++ b/mcp_servers/README.md @@ -87,7 +87,37 @@ 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. +Restart Claude Desktop. You should see a 🔨 tools icon indicating the server connected, with the following MCP tools available through the tools interface: + +* `start_browser` +* `close_browser` +* `browser_status` +* `navigate` +* `navigate_history` +* `get_page_info` +* `find_elements` +* `get_page_content` +* `get_attributes` +* `check_state` +* `get_all_urls` +* `click` +* `hover` +* `drag_and_drop` +* `fill_input` +* `select_option` +* `element_action` +* `wait_for` +* `assert_that` +* `manage_cookies` +* `manage_storage` +* `scroll` +* `manage_window` +* `manage_tabs` +* `solve_captcha` +* `save_output` +* `run_javascript` +* `wait_seconds` +* `get_user_agent` ## 4. Connect it to Claude Code @@ -123,19 +153,21 @@ claude mcp add seleniumbase-mcp -- uv run seleniumbase-mcp ## Tools exposed -| Group | Examples | +Tools here are grouped around a shared `selector` convention: `selector` args accept a CSS selector, or visible text (e.g. `a:contains("Sign in")`). Several near-identical one-off tools (e.g. separate click/wait/cookie/storage variants) have been consolidated into a single tool with a `mode`/`action`/`state`/`check` parameter, so there are fewer near-neighbor tools to disambiguate between while every underlying capability stays available. + +| Group | Tool(s) | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` (run JS) | +| Session | `start_browser(url, headless, use_chromium, browser_executable_path, incognito, guest, ad_block, proxy)`, `close_browser`, `browser_status` | +| Navigation | `navigate`, `navigate_history(action: back/forward/reload)`, `get_page_info` (url, title, origin, history in one call) | +| Finding & reading | `find_elements(selector, timeout, include_html)`, `get_page_content(selector, as_html, include_shadow_dom)`, `get_attributes`, `check_state(check: present/visible/count/text_visible)`, `get_all_urls` | +| Interacting | `click(selector, nth, all_matches, only_if_visible, parent_selector, timeout, scroll)`, `hover(selector, then_click_selector)`, `drag_and_drop`, `fill_input(mode: type/append/set_value/fast_type/clear)`, `select_option(by: text/value/index)`, `element_action(action: focus/highlight/scroll_into_view)` | +| 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`, `get_user_agent` | ## Design notes / things to adapt for your use case @@ -143,14 +175,14 @@ claude mcp add seleniumbase-mcp -- uv run seleniumbase-mcp - **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). +- **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. -- **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. +- **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. - **CAPTCHA-solving.** `solve_captcha` handles supported challenge types (e.g. Cloudflare Turnstile). -- **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. +- **Security.** `run_javascript` runs arbitrary JS, and `manage_storage` can expose authentication/session secrets; `manage_cookies` and `save_output` accept filenames/folders that can touch the filesystem. This server can also 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. +Adding a tool is just adding a `@mcp.tool()`-decorated function (wrapped in `handle_sb_errors`) that calls the matching `sb_cdp.Chrome` method — SeleniumBase has methods for file uploads, network conditions, and more that aren't wrapped above yet. diff --git a/mcp_servers/pyproject.toml b/mcp_servers/pyproject.toml index f27c61f85bb..b918f10df26 100644 --- a/mcp_servers/pyproject.toml +++ b/mcp_servers/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "seleniumbase-mcp" -version = "1.1.0dev0" +version = "1.2.0dev0" description = "MCP server exposing SeleniumBase CDP Mode as tools for MCP clients." readme = "README.md" requires-python = ">=3.10" diff --git a/mcp_servers/server.py b/mcp_servers/server.py index dc2e9901949..5111458c963 100644 --- a/mcp_servers/server.py +++ b/mcp_servers/server.py @@ -13,18 +13,28 @@ Model: One persistent `sb_cdp.Chrome` session per server process. Call start_browser once; drive it with the other tools; then close_browser. +Design notes (v2): +Tools are grouped around one CSS-selector-or-text-matched-by convention: +`selector` args accept a CSS selector, or visible text (e.g. +'a:contains("Sign in")'). Where the original tool set had several +near-identical tools for one concept (e.g. five click variants, five wait +variants, eight cookie/storage variants), those are now a single tool with +a mode/action/state/check parameter, to reduce the number of near-neighbor +tools an agent has to disambiguate between while keeping every underlying +capability available. + 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. +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. """ from __future__ import annotations 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 sb_cdp @@ -61,43 +71,70 @@ def wrapper(*args, **kwargs): def start_browser( url: str | None = None, headless: bool = False, + use_chromium: bool = False, + browser_executable_path: str | None = None, incognito: bool = False, guest: bool = False, - proxy: str | None = None, ad_block: bool = False, + proxy: str | None = None, ) -> 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. + headless: Run without a visible browser. (Mainly for macOS or Windows + because Xvfb automatically provides a virtual display on Linux.) + use_chromium: Use Chromium instead of Google Chrome. This is useful + on environments where Google Chrome is not installed because + SeleniumBase automatically downloads Chromium if it's not found. + browser_executable_path: If Google Chrome is not installed in the + default location, you can set the direct path with this arg. + (This option should not be used if setting use_chromium to True.) incognito: Launch in a private/incognito window. - guest: Launch in Chrome guest mode. + guest: Launch in Chrome guest mode. (Don't use with incognito mode) + ad_block: Enables basic ad-blocking functionality. 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." ) + if incognito and guest: + return "Error: incognito and guest cannot both be enabled." + if use_chromium and browser_executable_path: + return ( + "Error: use_chromium and browser_executable_path " + "cannot both be used at the same time." + ) kwargs: dict[str, Any] = {"headless": headless} + if use_chromium: + kwargs["use_chromium"] = True + if browser_executable_path: + kwargs["browser_executable_path"] = browser_executable_path if incognito: kwargs["incognito"] = True if guest: kwargs["guest"] = True - if proxy: - kwargs["proxy"] = proxy if ad_block: kwargs["ad_block"] = True + if proxy: + kwargs["proxy"] = proxy try: _sb = sb_cdp.Chrome(url, **kwargs) return ( f"Started Pure CDP Mode browser " - f"(url={url!r}, headless={headless})" + f"(url={url!r}, headless={headless}, " + f"use_chromium={use_chromium})" ) except Exception as e: + if _sb is not None: + try: + _sb.quit() + except Exception: + pass + _sb = None return ( f"Error starting browser: " f"{e.__class__.__name__} - {str(e).strip()}" @@ -118,6 +155,25 @@ def close_browser() -> str: return "Browser closed." +@mcp.tool() +def browser_status() -> dict: + """Return whether a browser session is currently active.""" + if _sb is None: + return {"running": False} + + try: + return { + "running": True, + "url": _sb.get_current_url(), + "title": _sb.get_title(), + } + except Exception as e: + return { + "running": False, + "error": f"{e.__class__.__name__}: {str(e).strip()}", + } + + # --------------------------------------------------------------------------- # Navigation # --------------------------------------------------------------------------- @@ -138,58 +194,40 @@ def navigate(url: str) -> str: @mcp.tool() @handle_sb_errors -def reload_page(ignore_cache: bool = True) -> str: - """Reload the current page. - Same as clicking the Reload button in the web browser. - By default, ignores the browser cache on reload.""" - _get_sb().reload(ignore_cache=ignore_cache) - return "Page reloaded." - - -@mcp.tool() -@handle_sb_errors -def go_back() -> str: - """Go back one page in browser history. - Same as clicking the Back button in the web browser.""" - _get_sb().go_back() - return "Navigated back." - - -@mcp.tool() -@handle_sb_errors -def go_forward() -> str: - """Go forward one page in browser history. - Same as clicking the Forward button in the web browser.""" - _get_sb().go_forward() - return "Navigated forward." - - -@mcp.tool() -@handle_sb_errors -def get_navigation_history() -> Any: - """Get the browser's navigation history.""" - return _get_sb().get_navigation_history() - - -@mcp.tool() -@handle_sb_errors -def get_current_url() -> str: - """Get the URL of the current page.""" - return _get_sb().get_current_url() - - -@mcp.tool() -@handle_sb_errors -def get_title() -> str: - """Get the title of the current page.""" - return _get_sb().get_title() +def navigate_history( + action: Literal["back", "forward", "reload"] = "back", +) -> str: + """Move within browser navigation history, or reload the current page. + Args: + action: 'back', 'forward', or 'reload' (reloads ignoring cache). + """ + sb = _get_sb() + if action == "back": + sb.go_back() + return "Navigated back." + if action == "forward": + sb.go_forward() + return "Navigated forward." + if action == "reload": + sb.reload(ignore_cache=True) + return "Page reloaded." + return ( + f"Error: unknown action '{action}'. " + "Use 'back', 'forward', or 'reload'." + ) @mcp.tool() @handle_sb_errors -def get_origin() -> str: - """Get the origin (scheme + host) of the current page.""" - return _get_sb().get_origin() +def get_page_info() -> dict | str: + """Get the current URL, title, origin, & navigation history in one call.""" + sb = _get_sb() + return { + "url": sb.get_current_url(), + "title": sb.get_title(), + "origin": sb.get_origin(), + "history": sb.get_navigation_history(), + } # --------------------------------------------------------------------------- @@ -198,97 +236,119 @@ def get_origin() -> str: @mcp.tool() @handle_sb_errors -def find_element_info( - selector: str, best_match: bool = False, timeout: int | None = None +def find_elements( + selector: str, + timeout: int | None = 7, + include_html: bool = False, ) -> dict | str: - """Find one element and return its tag name, text, and outer HTML. + """Find element(s) matching a CSS selector or visible text, and return + their tag name, text, and outer HTML (optional). 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() -@handle_sb_errors -def find_all_info( - selector: str, timeout: int | None = None -) -> list[dict] | str: - """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() -@handle_sb_errors -def get_text(selector: str = "body") -> str: - """Get the visible text within an element (default: whole page body). - Raises an exception if the element isn't found within the default timeout. + elements by visible text as well as by selector, e.g. + 'a:contains("Sign in")'). + timeout: Seconds to wait for at least one match to appear. + include_html: Whether to include the html with each matching element. + Returns a dict with 'count' (total matches found) and 'matches' (a list + of {tag_name, text, html} dicts, or {tag_name, text} dicts if not + including html). """ - return _get_sb().get_text(selector) - - -@mcp.tool() -@handle_sb_errors -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() -@handle_sb_errors -def get_element_html(selector: str) -> str: - """Get the outer HTML of a specific element.""" - return _get_sb().get_element_html(selector) - - -@mcp.tool() -@handle_sb_errors -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() -@handle_sb_errors -def get_element_attributes(selector: str) -> dict | str: - """Get all attributes of an element as a dict.""" - return _get_sb().get_element_attributes(selector) - - -@mcp.tool() -@handle_sb_errors -def find_elements_count( - selector: str, timeout: int | None = None -) -> int | str: - """Get the count of how many elements on the page match the selector.""" - return len(_get_sb().find_elements(selector, timeout=timeout)) - - -@mcp.tool() -@handle_sb_errors -def is_element_present(selector: str) -> bool | str: - """Return whether an element matching the selector exists in the DOM.""" - return _get_sb().is_element_present(selector) + sb = _get_sb() + els = sb.find_all(selector, timeout=timeout) + if include_html: + return { + "count": len(els), + "matches": [ + { + "tag_name": e.tag_name, + "text": e.text, + "html": e.get_html(), + } for e in els + ], + } + else: + return { + "count": len(els), + "matches": [ + { + "tag_name": e.tag_name, + "text": e.text, + } for e in els + ], + } + + +@mcp.tool() +@handle_sb_errors +def get_page_content( + selector: str | None = None, + as_html: bool = False, + include_shadow_dom: bool = True, +) -> str: + """Get the visible text or HTML of an element, or of the whole page. + Args: + selector: Element to read from. Omit (or pass None) to read the + whole page instead of one element. + as_html: If True, return HTML instead of visible text. + include_shadow_dom: Only applies when reading the whole page as HTML. + """ + sb = _get_sb() + if selector is None: + if as_html: + return sb.get_page_source(include_shadow_dom=include_shadow_dom) + return sb.get_text("body") + if as_html: + return sb.get_element_html(selector) + return sb.get_text(selector) @mcp.tool() @handle_sb_errors -def is_element_visible(selector: str) -> bool | str: - """Return whether an element matching the selector is visible.""" - return _get_sb().is_element_visible(selector) +def get_attributes(selector: str, attribute: str | None = None) -> Any: + """Get one attribute's value from an element, or all of its attributes + as a dict if `attribute` isn't given.""" + sb = _get_sb() + if attribute: + return sb.get_element_attribute(selector, attribute) + return sb.get_element_attributes(selector) @mcp.tool() @handle_sb_errors -def is_text_visible(text: str, selector: str = "body") -> bool | str: - """Return whether the specific text is visible within an element.""" - return _get_sb().is_text_visible(text, selector) +def check_state( + check: Literal["present", "visible", "count", "text_visible"] = "visible", + selector: str = "body", + text: str | None = None, +) -> Any: + """Check the current state of the page or an element. + Unless setting 'count', where it may wait up to 1 second, this never waits. + Never raises exceptions — use wait_for if you want to wait for a state. + For 'count', if there are no matching elements, then it waits up to + 1 second for a single match to appear. If no matches after 1 second, + then 'count' returns 0. + Args: + check: 'present', 'visible', 'count', 'text_visible'. + (`text_visible` requires value for `text`.) + selector: The CSS Selector for the chosen check. + text: The text to use for the `text_visible` check. + """ + sb = _get_sb() + if check == "present": + return sb.is_element_present(selector) + if check == "visible": + return sb.is_element_visible(selector) + if check == "count": + return len(sb.find_elements(selector, timeout=1)) + if check == "text_visible": + if text is None: + return ( + "Error: The 'text_visible' check requires value for 'text'." + ) + return sb.is_text_visible(text, selector) + return ( + f"Error: unknown check '{check}'. " + "Use 'present', 'visible', 'count', or 'text_visible'." + ) @mcp.tool() @@ -305,302 +365,290 @@ def get_all_urls(absolute: bool = True) -> list[str] | str: @mcp.tool() @handle_sb_errors def click( - selector: str, timeout: int | None = None, scroll: bool = True + selector: str, + nth: int | None = None, + all_matches: bool = False, + only_if_visible: bool = False, + parent_selector: str | None = None, + timeout: int | None = 7, + scroll: bool = True, ) -> str: - """Click an element matched by a CSS selector (or by text, e.g. - 'a:contains("Sign in")'). - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().click(selector, timeout=timeout, scroll=scroll) - return f"Clicked {selector}" - - -@mcp.tool() -@handle_sb_errors -def click_if_visible(selector: str, timeout: int = 0) -> str: - """Click an element only if it's currently visible; no-op otherwise. - If a `timeout` is given, then waits up to that long for the element - to appear first before performing the click.""" - _get_sb().click_if_visible(selector, timeout=timeout) - return f"click_if_visible ran for {selector}" - - -@mcp.tool() -@handle_sb_errors -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() -@handle_sb_errors -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() -@handle_sb_errors -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() -@handle_sb_errors -def type_text(selector: str, text: str, timeout: int | None = None) -> str: - """Clear a field and type text into it. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().type(selector, text, timeout=timeout) - return f"Typed into {selector}" - - -@mcp.tool() -@handle_sb_errors -def send_keys(selector: str, text: str, timeout: int | None = None) -> str: - """Send keystrokes to an element without clearing it first. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().send_keys(selector, text, timeout=timeout) - return f"Sent keys to {selector}" - - -@mcp.tool() -@handle_sb_errors -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). - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().set_value(selector, text, timeout=timeout) - return f"Set value of {selector}" - - -@mcp.tool() -@handle_sb_errors -def clear_input(selector: str, timeout: int | None = None) -> str: - """Clear an input field. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout. """ - _get_sb().clear_input(selector, timeout=timeout) - return f"Cleared {selector}" - - -@mcp.tool() -@handle_sb_errors -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() -@handle_sb_errors -def select_option_by_text(dropdown_selector: str, option: str) -> str: - """Select a dropdown option by its value attribute. - Raises an exception if the element or option aren't found - within the default timeout, which is 7 seconds.""" - _get_sb().select_option_by_value(dropdown_selector, option) - return f"Selected value '{option}' in {dropdown_selector}" - - -@mcp.tool() -@handle_sb_errors -def select_option_by_index(dropdown_selector: str, option: int) -> str: - """Select a dropdown. + Raises an exception if the element or option aren't found within the + default timeout, which is 7 seconds. + Args: + value: The option's visible text, its `value` attribute, or its + 0-based index (as a string), depending on `by`. + by: 'text' (default), 'value', or 'index'. + Using "index" for `by` is type-safe. (Eg. 4 and "4" both work the same) + """ + sb = _get_sb() + if by == "text": + sb.select_option_by_text(dropdown_selector, str(value)) + elif by == "value": + sb.select_option_by_value(dropdown_selector, str(value)) + elif by == "index": + sb.select_option_by_index(dropdown_selector, int(value)) + else: + return f"Error: unknown by='{by}'. Use 'text', 'value', or 'index'." + return f"Selected ({by}={value!r}) in {dropdown_selector}" @mcp.tool() @handle_sb_errors -def wait_for_text( - text: str, selector: str = "body", timeout: int | None = None +def element_action( + selector: str, + action: Literal["focus", "highlight", "scroll_into_view"] = "focus", ) -> str: - """Wait until the text substring appears within an element. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't visible within the timeout.""" - _get_sb().wait_for_text(text, selector, timeout=timeout) - return f"Text '{text}' appeared in {selector}." + """Perform a simple positioning/emphasis action on an element. + Raises an exception if the element isn't found within the default + timeout. + Args: + action: 'focus' (move keyboard focus to it), 'highlight' (briefly + flash it using JavaScript — useful for narrating actions + on a visible/headed browser), or 'scroll_into_view'. + """ + sb = _get_sb() + if 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'." + ) + return f"{action} done for {selector}" # --------------------------------------------------------------------------- -# Assertions (raise an error, surfaced to the MCP client, if they fail) +# Waiting & assertions # --------------------------------------------------------------------------- @mcp.tool() @handle_sb_errors -def assert_element(selector: str, timeout: int | None = None) -> str: - """Assert that an element is present in the DOM. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().assert_element(selector, timeout=timeout) - return f"Confirmed {selector} is present." - - -@mcp.tool() -@handle_sb_errors -def assert_element_visible(selector: str, timeout: int | None = None) -> str: - """Assert that an element is visible on the page. - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found within the timeout.""" - _get_sb().assert_element_visible(selector, timeout=timeout) - return f"Confirmed {selector} is visible." - - -@mcp.tool() -@handle_sb_errors -def assert_text( - text: str, selector: str = "html", timeout: int | None = None +def wait_for( + state: Literal["present", "visible", "not_visible", "absent"] = "visible", + selector: str | None = None, + text: str | None = None, + timeout: int | None = 7, ) -> str: - """Assert that the text substring appears within the given element - (with the matching selector) in the given timeout (seconds), - with leading and trailing whitespace automatically ignored. - If no `selector` given, then it defaults to "html" (CSS selector). + """Wait for an element or text to reach a given state before returning. If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found or assertion fails.""" - _get_sb().assert_text(text, selector, timeout=timeout) - return f"Confirmed '{text}' is present in {selector}." + Raises an exception if the state isn't reached within the timeout. + Args: + state: 'present', 'visible', 'not_visible', or 'absent' — describes + what `selector` should reach. Ignored if `text` is given. + selector: Element to wait on. + Defaults to 'body' when waiting on `text`. + text: If given, waits for this text to appear within `selector` + instead of waiting on the element's presence/visibility. + timeout: Seconds to wait. + """ + sb = _get_sb() + if selector is None and text is None: + return "Error: `selector` and `text` cannot both be None." + if text is not None: + sb.wait_for_text(text, selector or "body", timeout=timeout) + return f"Text '{text}' appeared in {selector or 'body'}." + if state == "present": + sb.wait_for_element_present(selector, timeout=timeout) + elif state == "visible": + sb.wait_for_element_visible(selector, timeout=timeout) + elif state == "not_visible": + sb.wait_for_element_not_visible(selector, timeout=timeout) + elif state == "absent": + sb.wait_for_element_absent(selector, timeout=timeout) + else: + return ( + f"Error: unknown state '{state}'. " + "Use 'present', 'visible', 'not_visible', or 'absent'." + ) + return f"Element {selector} reached state '{state}'." @mcp.tool() @handle_sb_errors -def assert_exact_text( - text: str, selector: str = "html", timeout: int | None = None +def assert_that( + check: Literal[ + "element_present", + "element_visible", + "text", + "title", + "url", + "url_contains" + ] = "element_visible", + selector: str | None = None, + expected: str | None = None, + exact: bool = False, + timeout: int | None = 7, ) -> str: - """Assert that the text matches the element's text exactly - (with leading/trailing whitespace automatically ignored) - in the given timeout (seconds). - If no `selector` given, then it defaults to "html" (CSS selector). - If no `timeout` given (0 or None), then SeleniumBase uses 7 seconds. - Raises an exception if the element isn't found or assertion fails.""" - _get_sb().assert_exact_text(text, selector, timeout=timeout) - return f"Confirmed {selector} text is exactly '{text}'." - - -@mcp.tool() -@handle_sb_errors -def assert_title(title: str) -> str: - """Assert that the title matches the page title exactly, - with leading and trailing whitespace ignored. - Raises an exception if the expected title doesn't - match the actual title within 7 seconds.""" - _get_sb().assert_title(title) - return f"Confirmed title is '{title}'." - - -@mcp.tool() -@handle_sb_errors -def assert_url(url: str) -> str: - """Assert that the url matches the current URL exactly. - Raises an exception if the expected url doesn't - match the actual url within 7 seconds.""" - _get_sb().assert_url(url) - return f"Confirmed URL is '{url}'." - - -@mcp.tool() -@handle_sb_errors -def assert_url_contains(substring: str) -> str: - """Assert that the current URL contains the given substring. - Raises an exception if the expected substring isn't - found in the actual url within 7 seconds.""" - _get_sb().assert_url_contains(substring) - return f"Confirmed URL contains '{substring}'." + """Assert a condition about the page or an element. Raises an exception + (surfaced back as an error string) if the assertion fails within the + timeout (default 7 seconds). `timeout` applies only to element/text checks. + (For url or title checks, the assertion either passes or fails right away.) + Args: + check: 'element_present', 'element_visible' (need `selector`); + 'text' (substring of `expected` within `selector`, default + 'html'); 'title', 'url' (exact match), or 'url_contains' + (need `expected`). + selector: Element to check. Used by 'element_present', + 'element_visible', and 'text'. + expected: The text/title/url to check against. Not used for the + element-only checks. + exact: For check='text', require an exact match instead of a + substring match. + timeout: Seconds to wait before failing. + """ + sb = _get_sb() + if check in ("element_present", "element_visible") and selector is None: + return f"Error: check='{check}' requires value for `selector`." + if check in ("text", "title", "url", "url_contains") and expected is None: + return f"Error: check='{check}' requires value for `expected`." + if check == "element_present": + sb.assert_element(selector, timeout=timeout) + return f"Confirmed {selector} is present." + if check == "element_visible": + sb.assert_element_visible(selector, timeout=timeout) + return f"Confirmed {selector} is visible." + if check == "text": + target = selector or "html" + if exact: + sb.assert_exact_text(expected, target, timeout=timeout) + else: + sb.assert_text(expected, target, timeout=timeout) + return f"Confirmed text in {target}." + if check == "title": + sb.assert_title(expected) + return f"Confirmed title is '{expected}'." + if check == "url": + sb.assert_url(expected) + return f"Confirmed URL is '{expected}'." + if check == "url_contains": + sb.assert_url_contains(expected) + return f"Confirmed URL contains '{expected}'." + return ( + f"Error: unknown check '{check}'. Use 'element_present', " + f"'element_visible', 'text', 'title', 'url', or 'url_contains'." + ) # --------------------------------------------------------------------------- @@ -609,63 +657,66 @@ def assert_url_contains(substring: str) -> str: @mcp.tool() @handle_sb_errors -def get_all_cookies() -> Any: - """Get all cookies for the current session.""" - return _get_sb().get_all_cookies() - - -@mcp.tool() -@handle_sb_errors -def clear_cookies() -> str: - """Clear all cookies.""" - _get_sb().clear_cookies() - return "Cookies cleared." - - -@mcp.tool() -@handle_sb_errors -def save_cookies(name: str = "cookies.txt") -> str: - """Save current cookies to a file.""" - _get_sb().save_cookies(name=name) - return f"Cookies saved to {name}" - - -@mcp.tool() -@handle_sb_errors -def load_cookies(name: str = "cookies.txt") -> str: - """Load cookies from a previously saved file.""" - _get_sb().load_cookies(name=name) - return f"Cookies loaded from {name}" - - -@mcp.tool() -@handle_sb_errors -def get_local_storage_item(key: str) -> Any: - """Get a value from the page's localStorage.""" - return _get_sb().get_local_storage_item(key) - - -@mcp.tool() -@handle_sb_errors -def set_local_storage_item(key: str, value: str) -> str: - """Set a value in the page's localStorage.""" - _get_sb().set_local_storage_item(key, value) - return f"Set localStorage[{key!r}]" - - -@mcp.tool() -@handle_sb_errors -def get_session_storage_item(key: str) -> Any: - """Get a value from the page's sessionStorage.""" - return _get_sb().get_session_storage_item(key) +def manage_cookies( + action: Literal["get_all", "clear", "save", "load"] = "get_all", + filename: str = "cookies.txt" +) -> Any: + """Get, clear, save, or load browser cookies. + Args: + action: 'get_all', 'clear', 'save' (to `filename`), or 'load' + (from `filename`). + SECURITY: `filename` can potentially expose filesystem operations + to an MCP client. Existing files could get overwritten via 'save'. + """ + sb = _get_sb() + if action == "get_all": + return sb.get_all_cookies() + if action == "clear": + sb.clear_cookies() + return "Cookies cleared." + if action == "save": + sb.save_cookies(name=filename) + return f"Cookies saved to {filename}" + if action == "load": + sb.load_cookies(name=filename) + return f"Cookies loaded from {filename}" + return ( + f"Error: unknown action '{action}'. " + "Use 'get_all', 'clear', 'save', or 'load'." + ) @mcp.tool() @handle_sb_errors -def set_session_storage_item(key: str, value: str) -> str: - """Set a value in the page's sessionStorage.""" - _get_sb().set_session_storage_item(key, value) - return f"Set sessionStorage[{key!r}]" +def manage_storage( + key: str, + value: str | None = None, + storage: Literal["local", "session"] = "local", + action: Literal["get", "set"] = "get", +) -> Any: + """Get or set a key in the page's localStorage or sessionStorage. + Args: + storage: 'local' or 'session'. + action: 'get' or 'set'. ('set' requires `value`). + WARNING: This tool can expose authentication/session secrets. + Only use against trusted sites and MCP clients. + """ + sb = _get_sb() + if action not in ("get", "set"): + return "Error: action must be 'get' or 'set'." + if action == "set" and value is None: + return "Error: value is required when action='set'." + if storage == "local": + if action == "get": + return sb.get_local_storage_item(key) + sb.set_local_storage_item(key, value) + return f"Set localStorage[{key!r}]" + if storage == "session": + if action == "get": + return sb.get_session_storage_item(key) + sb.set_session_storage_item(key, value) + return f"Set sessionStorage[{key!r}]" + return f"Error: unknown storage '{storage}'. Use 'local' or 'session'." # --------------------------------------------------------------------------- @@ -674,42 +725,30 @@ def set_session_storage_item(key: str, value: str) -> str: @mcp.tool() @handle_sb_errors -def scroll_into_view(selector: str) -> str: - """Scroll an element into view.""" - _get_sb().scroll_into_view(selector) - return f"Scrolled {selector} into view." - - -@mcp.tool() -@handle_sb_errors -def scroll_to_top() -> str: - """Scroll to the top of the page.""" - _get_sb().scroll_to_top() - return "Scrolled to top." - - -@mcp.tool() -@handle_sb_errors -def scroll_to_bottom() -> str: - """Scroll to the bottom of the page.""" - _get_sb().scroll_to_bottom() - return "Scrolled to bottom." - - -@mcp.tool() -@handle_sb_errors -def scroll_up(amount: int = 25) -> str: - """Scroll up by a relative amount.""" - _get_sb().scroll_up(amount=amount) - return f"Scrolled up {amount}." - - -@mcp.tool() -@handle_sb_errors -def scroll_down(amount: int = 25) -> str: - """Scroll down by a relative amount.""" - _get_sb().scroll_down(amount=amount) - return f"Scrolled down {amount}." +def scroll( + direction: Literal["up", "down", "top", "bottom"] = "down", + amount: int = 25, +) -> str: + """Scroll the page. + Args: + direction: 'up' or 'down' (relative, by `amount`), 'top', or 'bottom'. + amount: Relative scroll distance; only used for 'up'/'down'. + """ + sb = _get_sb() + if direction == "up": + sb.scroll_up(amount=amount) + elif direction == "down": + sb.scroll_down(amount=amount) + elif direction == "top": + sb.scroll_to_top() + elif direction == "bottom": + sb.scroll_to_bottom() + else: + return ( + f"Error: unknown direction '{direction}'. " + "Use 'up', 'down', 'top', or 'bottom'." + ) + return f"Scrolled {direction}." # --------------------------------------------------------------------------- @@ -718,73 +757,104 @@ def scroll_down(amount: int = 25) -> str: @mcp.tool() @handle_sb_errors -def get_window_rect() -> dict | str: - """Get the current window's position and size.""" - return _get_sb().get_window_rect() - - -@mcp.tool() -@handle_sb_errors -def set_window_rect(x: int, y: int, width: int, height: int) -> str: - """Set the current window's position and size.""" - _get_sb().set_window_rect(x, y, width, height) - return f"Window set to ({x}, {y}, {width}x{height})" - - -@mcp.tool() -@handle_sb_errors -def maximize() -> str: - """Maximize the browser window.""" - _get_sb().maximize() - return "Window maximized." - - -@mcp.tool() -@handle_sb_errors -def minimize() -> str: - """Minimize the browser window.""" - _get_sb().minimize() - return "Window minimized." - - -@mcp.tool() -@handle_sb_errors -def open_new_tab(url: str | None = None, switch_to: bool = True) -> str: - """Open a new browser tab, optionally navigating and switching to it.""" - _get_sb().open_new_tab(url=url, switch_to=switch_to) - return f"Opened new tab (url={url!r}, switch_to={switch_to})" - - -@mcp.tool() -@handle_sb_errors -def switch_to_tab(tab_index: int) -> str: - """Switch to a tab by its index (as returned by get_tabs).""" - tabs = _get_sb().get_tabs() - _get_sb().switch_to_tab(tabs[tab_index]) - return f"Switched to tab {tab_index}" - - -@mcp.tool() -@handle_sb_errors -def switch_to_newest_tab() -> str: - """Switch to the most recently opened tab.""" - _get_sb().switch_to_newest_tab() - return "Switched to newest tab." - - -@mcp.tool() -@handle_sb_errors -def close_active_tab() -> str: - """Close the currently active tab.""" - _get_sb().close_active_tab() - return "Closed active tab." +def manage_window( + action: Literal[ + "get_rect", + "set_rect", + "maximize", + "minimize", + ] = "get_rect", + x: int | None = None, + y: int | None = None, + width: int | None = None, + height: int | None = None, +) -> Any: + """Get or change the browser window's size, position, or state. + Args: + action: 'get_rect', 'set_rect' (requires x, y, width, height), + 'maximize', or 'minimize'. + """ + sb = _get_sb() + if action == "get_rect": + return sb.get_window_rect() + if action == "set_rect": + if None in (x, y, width, height): + return "Error: set_rect requires x, y, width, and height." + sb.set_window_rect(x, y, width, height) + return f"Window set to ({x}, {y}, {width}x{height})" + if action == "maximize": + sb.maximize() + return "Window maximized." + if action == "minimize": + sb.minimize() + return "Window minimized." + return ( + f"Error: unknown action '{action}'. " + "Use 'get_rect', 'set_rect', 'maximize', or 'minimize'." + ) @mcp.tool() @handle_sb_errors -def get_tabs_count() -> int | str: - """Get how many tabs are currently open.""" - return len(_get_sb().get_tabs()) +def manage_tabs( + action: Literal[ + "list", + "open", + "switch", + "switch_newest", + "close_active", + ] = "list", + url: str | None = None, + tab_index: int | None = None, + switch_to: bool = True, +) -> Any: + """List, open, switch between, or close browser tabs. + Args: + action: 'list' (returns each open tab's index/url/title — call this + before 'switch' to find the right tab_index), 'open' (a new + tab, optionally navigating to `url`), 'switch' (to `tab_index`), + 'switch_newest', or 'close_active'. + url: Used with action='open'. + tab_index: Used with action='switch'; the index as returned by 'list'. + switch_to: Used with action='open'; whether to switch to the new tab. + """ + sb = _get_sb() + if action == "list": + tabs = sb.get_tabs() + return [ + { + "index": i, "url": getattr(t, "url", None), + "title": getattr(t, "title", None) + } + for i, t in enumerate(tabs) + ] + if action == "open": + sb.open_new_tab(url=url, switch_to=switch_to) + return f"Opened new tab (url={url!r}, switch_to={switch_to})" + if action == "switch": + if tab_index is None: + return ( + "Error: action='switch' requires tab_index " + "(see action='list')." + ) + tabs = sb.get_tabs() + if tab_index < 0 or tab_index >= len(tabs): + return ( + f"Error: tab_index={tab_index} out of range. " + f"Available indexes: 0-{len(tabs) - 1}." + ) + sb.switch_to_tab(tabs[tab_index]) + return f"Switched to tab {tab_index}" + if action == "switch_newest": + sb.switch_to_newest_tab() + return "Switched to newest tab." + if action == "close_active": + sb.close_active_tab() + return "Closed active tab." + return ( + f"Error: unknown action '{action}'. Use 'list', 'open', 'switch', " + f"'switch_newest', or 'close_active'." + ) # --------------------------------------------------------------------------- @@ -794,7 +864,7 @@ def get_tabs_count() -> int | str: @mcp.tool() @handle_sb_errors def solve_captcha() -> str: - """Attempt to solve a captcha (e.g. Cloudflare Turnstile) on the page.""" + """Attempt to solve a CAPTCHA (e.g. Cloudflare Turnstile) on the page.""" _get_sb().solve_captcha() return "Attempted captcha solve." @@ -805,48 +875,57 @@ def solve_captcha() -> str: @mcp.tool() @handle_sb_errors -def save_screenshot( - name: str = "screenshot.png", folder: str | None = None -) -> str: - """Save a screenshot of the current page.""" - _get_sb().save_screenshot(name, folder=folder) - return f"Screenshot saved as {name}" - - -@mcp.tool() -@handle_sb_errors -def save_page_source( - name: str = "page_source.html", folder: str | None = None +def save_output( + format: Literal["screenshot", "html", "pdf"] = "screenshot", + filename: str | None = None, + folder: str | None = None ) -> str: - """Save the current page's HTML source to a file.""" - _get_sb().save_page_source(name, folder=folder) - return f"Page source saved as {name}" - - -@mcp.tool() -@handle_sb_errors -def save_as_pdf(name: str = "page.pdf", folder: str | None = None) -> str: - """Print the current page to a PDF file.""" - _get_sb().save_as_pdf(name, folder=folder) - return f"Page saved as PDF: {name}" + """Save the current page as a screenshot, HTML source, or PDF file. + Args: + format: 'screenshot', 'html', or 'pdf'. + filename: Output filename. Defaults to 'screenshot.png', + 'page_source.html', or 'page.pdf' depending on `format`. + folder: Optional folder to save into. + If 'screenshot' format, then the page is saved as a PNG (.png) file. + If 'html' format, then the page source is saved to an HTML (.html) file. + If 'pdf' format, then the page is saved as a PDF (.pdf) file. + SECURITY: `filename`/`folder` can potentially expose filesystem operations + to an MCP client. Existing files could get overwritten with the save. + """ + sb = _get_sb() + if format == "screenshot": + name = filename or "screenshot.png" + sb.save_screenshot(name, folder=folder) + elif format == "html": + name = filename or "page_source.html" + sb.save_page_source(name, folder=folder) + elif format == "pdf": + name = filename or "page.pdf" + sb.save_as_pdf(name, folder=folder) + else: + return ( + f"Error: unknown format '{format}'. " + "Use 'screenshot', 'html', or 'pdf'." + ) + return f"Saved {format} as {name}" @mcp.tool() @handle_sb_errors -def evaluate(expression: str) -> Any: +def run_javascript(expression: str) -> Any: """Evaluate a JavaScript expression in the page context and return the - result. Equivalent to execute_script. This method can run any arbitrary - JavaScript on any site, so take any necessary precautions to prevent - AI harnesses from running scripts that you don't want them to run.""" + result. This method can run any arbitrary JavaScript on any visited site. + SECURITY: This provides unrestricted JavaScript execution in the browser + context. Only expose this MCP server to trusted clients.""" return _get_sb().evaluate(expression) @mcp.tool() @handle_sb_errors -def sleep(seconds: float) -> str: +def wait_seconds(seconds: float) -> str: """Pause execution for a number of seconds.""" _get_sb().sleep(seconds) - return f"Slept {seconds}s" + return f"Waited {seconds}s" @mcp.tool() diff --git a/mkdocs_build/requirements.txt b/mkdocs_build/requirements.txt index 24cbb60d9e8..784cb06be97 100644 --- a/mkdocs_build/requirements.txt +++ b/mkdocs_build/requirements.txt @@ -1,7 +1,7 @@ # mkdocs dependencies for generating the seleniumbase.io website # Minimum Python version: 3.10 (for generating docs only) -regex>=2026.7.19 +regex>=2026.8.31 pymdown-extensions>=10.21.3 pipdeptree>=4.2.2 python-dateutil>=2.8.2 diff --git a/seleniumbase/__version__.py b/seleniumbase/__version__.py index 7389a467d1e..c187c63478b 100755 --- a/seleniumbase/__version__.py +++ b/seleniumbase/__version__.py @@ -1,2 +1,2 @@ # seleniumbase package -__version__ = "4.53.0" +__version__ = "4.53.1" diff --git a/seleniumbase/core/sb_cdp.py b/seleniumbase/core/sb_cdp.py index 70df1d9aa49..89b9101e279 100644 --- a/seleniumbase/core/sb_cdp.py +++ b/seleniumbase/core/sb_cdp.py @@ -1475,6 +1475,12 @@ def open_new_tab(self, url=None, switch_to=True, **kwargs): driver = self.driver if not isinstance(url, str): url = "about:blank" + if url and not page_utils.is_valid_url(url): + new_url = "https://" + url + if not page_utils.is_valid_url(new_url): + raise Exception(f"Invalid URL: {url}") + else: + url = new_url if hasattr(driver, "cdp_base"): try: self.loop.run_until_complete( diff --git a/seleniumbase/fixtures/base_case.py b/seleniumbase/fixtures/base_case.py index 8df73ba352c..54a98d1a10b 100644 --- a/seleniumbase/fixtures/base_case.py +++ b/seleniumbase/fixtures/base_case.py @@ -4058,11 +4058,17 @@ def set_content_to_parent_frame(self): def open_new_window(self, switch_to=True, **kwargs): """Opens a new browser tab/window and switches to it by default.""" url = None - if self.__looks_like_a_page_url(str(switch_to)): + if "." in str(switch_to): # Different API for CDP Mode: First arg is a `url`. # (Also, don't break backwards compat for reg mode) url = switch_to switch_to = True + if url and not page_utils.is_valid_url(url): + new_url = "https://" + url + if not page_utils.is_valid_url(new_url): + raise Exception(f"Invalid URL: {url}") + else: + url = new_url if self.__is_cdp_swap_needed(): self.cdp.open_new_tab(url=url, switch_to=switch_to, **kwargs) return diff --git a/setup.py b/setup.py index 36f51df8dc5..b3f5c6da12f 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ print("\n*** Installing build: *** (Required for PyPI uploads)\n") os.system("python -m pip install --upgrade 'build'") print("\n*** Installing pkginfo: *** (Required for PyPI uploads)\n") - os.system("python -m pip install 'pkginfo'") + os.system("python -m pip install --upgrade 'pkginfo'") print("\n*** Installing readme-renderer: *** (For PyPI uploads)\n") os.system("python -m pip install --upgrade 'readme-renderer'") print("\n*** Installing jaraco.classes: *** (For PyPI uploads)\n")