#
tokens: 8617/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .github
│   └── workflows
│       └── release.yml
├── .gitignore
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│   └── mcp_browser_use
│       ├── __init__.py
│       ├── server.py
│       └── utils.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
  1 | # Byte-compiled / optimized / DLL files
  2 | __pycache__/
  3 | *.py[cod]
  4 | *$py.class
  5 | 
  6 | # C extensions
  7 | *.so
  8 | 
  9 | # Distribution / packaging
 10 | .Python
 11 | build/
 12 | develop-eggs/
 13 | dist/
 14 | downloads/
 15 | eggs/
 16 | .eggs/
 17 | lib/
 18 | lib64/
 19 | parts/
 20 | sdist/
 21 | var/
 22 | wheels/
 23 | share/python-wheels/
 24 | *.egg-info/
 25 | .installed.cfg
 26 | *.egg
 27 | MANIFEST
 28 | 
 29 | # PyInstaller
 30 | #  Usually these files are written by a python script from a template
 31 | #  before PyInstaller builds the exe, so as to inject date/other infos into it.
 32 | *.manifest
 33 | *.spec
 34 | 
 35 | # Installer logs
 36 | pip-log.txt
 37 | pip-delete-this-directory.txt
 38 | 
 39 | # Unit test / coverage reports
 40 | htmlcov/
 41 | .tox/
 42 | .nox/
 43 | .coverage
 44 | .coverage.*
 45 | .cache
 46 | nosetests.xml
 47 | coverage.xml
 48 | *.cover
 49 | *.py,cover
 50 | .hypothesis/
 51 | .pytest_cache/
 52 | cover/
 53 | 
 54 | # Translations
 55 | *.mo
 56 | *.pot
 57 | 
 58 | # Django stuff:
 59 | *.log
 60 | local_settings.py
 61 | db.sqlite3
 62 | db.sqlite3-journal
 63 | 
 64 | # Flask stuff:
 65 | instance/
 66 | .webassets-cache
 67 | 
 68 | # Scrapy stuff:
 69 | .scrapy
 70 | 
 71 | # Sphinx documentation
 72 | docs/_build/
 73 | 
 74 | # PyBuilder
 75 | .pybuilder/
 76 | target/
 77 | 
 78 | # Jupyter Notebook
 79 | .ipynb_checkpoints
 80 | 
 81 | # IPython
 82 | profile_default/
 83 | ipython_config.py
 84 | 
 85 | # pyenv
 86 | #   For a library or package, you might want to ignore these files since the code is
 87 | #   intended to run in multiple environments; otherwise, check them in:
 88 | .python-version
 89 | 
 90 | # pipenv
 91 | #   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
 92 | #   However, in case of collaboration, if having platform-specific dependencies or dependencies
 93 | #   having no cross-platform support, pipenv may install dependencies that don't work, or not
 94 | #   install all needed dependencies.
 95 | #Pipfile.lock
 96 | 
 97 | # poetry
 98 | #   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
 99 | #   This is especially recommended for binary packages to ensure reproducibility, and is more
100 | #   commonly ignored for libraries.
101 | #   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 | 
104 | # pdm
105 | #   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | #   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | #   in version control.
109 | #   https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 | 
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 | 
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 | 
119 | # SageMath parsed files
120 | *.sage.py
121 | 
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 | 
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 | 
135 | # Rope project settings
136 | .ropeproject
137 | 
138 | # mkdocs documentation
139 | /site
140 | 
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 | 
146 | # Pyre type checker
147 | .pyre/
148 | 
149 | # pytype static type analyzer
150 | .pytype/
151 | 
152 | # Cython debug symbols
153 | cython_debug/
154 | 
155 | # PyCharm
156 | #  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | #  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | #  and can be added to the global gitignore or merged into this file.  For a more nuclear
159 | #  option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # mcp-browser-use: MCP server for browser-use
 2 | 
 3 | [![Version](https://img.shields.io/pypi/v/mcp-browser-use.svg)](https://pypi.org/project/mcp-browser-use/) [![Python Versions](https://img.shields.io/pypi/pyversions/mcp-browser-use.svg)](https://pypi.org/project/mcp-browser-use/) [![License](https://img.shields.io/pypi/l/mcp-browser-use.svg)](https://pypi.org/project/mcp-browser-use/)
 4 | 
 5 | **mcp-browser-use** is the easiest way to connect any MCP client (like Claude or Cursor) with the browser using [browser-use](https://github.com/browser-use/browser-use).
 6 | 
 7 | Unlike other `browser-use` MCPs that make you pay for an LLM API key, this one just uses the LLM that's already set up in your MCP client.
 8 | 
 9 | [📺 Demo](https://x.com/vortex_ape/status/1900953901588729864)
10 | 
11 | ## Quickstart
12 | 
13 | You can start using `mcp-browser-use` with an MCP client by putting the following command in the relevant config:
14 | 
15 | ```bash
16 | uvx mcp-browser-use
17 | ```
18 | 
19 | **Note**: Provide the full path to uvx to prevent MCP client failing to start the server.
20 | 
21 | ## Contributing
22 | 
23 | Contributions are welcome! Please feel free to submit a pull request.
24 | 
25 | ## Versioning
26 | 
27 | `mcp-browser-use` uses [Semantic Versioning](https://semver.org/). For the available versions, see the tags on the GitHub repository.
28 | 
29 | ## License
30 | 
31 | This project is licensed under the Apache 2.0 License, see the [LICENSE](https://github.com/vinayak-mehta/mcp-browser-use/blob/master/LICENSE) file for details.
32 | 
```

--------------------------------------------------------------------------------
/src/mcp_browser_use/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "mcp-browser-use"
 3 | version = "0.1.5"
 4 | description = "MCP server for browser-use"
 5 | requires-python = ">=3.10"
 6 | authors = [
 7 |     {name = "Vinayak Mehta", email = "[email protected]"},
 8 | ]
 9 | dependencies = [
10 |     "mcp>=0.1.0",
11 |     "python-dotenv>=1.0.0",
12 |     "fastapi>=0.109.0",
13 |     "uvicorn>=0.27.0",
14 |     "playwright>=1.50.0",
15 |     "browser-use>=0.1.40",
16 | ]
17 | readme = "README.md"
18 | license = {text = "Apache-2.0"}
19 | classifiers = [
20 |     "Programming Language :: Python :: 3",
21 |     "Programming Language :: Python :: 3.11",
22 |     "Programming Language :: Python :: 3.12",
23 |     "Programming Language :: Python :: 3.13",
24 |     "License :: OSI Approved :: Apache Software License",
25 | ]
26 | 
27 | [project.urls]
28 | "Homepage" = "https://github.com/vinayak-mehta/mcp-browser-use"
29 | "Bug Tracker" = "https://github.com/vinayak-mehta/mcp-browser-use/issues"
30 | 
31 | [project.scripts]
32 | mcp-browser-use = "mcp_browser_use.server:main"
33 | 
34 | [build-system]
35 | requires = ["setuptools>=42", "wheel"]
36 | build-backend = "setuptools.build_meta"
37 | 
38 | [tool.setuptools]
39 | package-dir = {"" = "src"}
40 | packages = ["mcp_browser_use"]
41 | 
```

--------------------------------------------------------------------------------
/src/mcp_browser_use/utils.py:
--------------------------------------------------------------------------------

```python
 1 | # ruff: noqa: E402
 2 | 
 3 | import logging
 4 | import os
 5 | import subprocess
 6 | import sys
 7 | 
 8 | logging.basicConfig(
 9 |     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
10 | )
11 | logger = logging.getLogger(__name__)
12 | 
13 | logging.getLogger("browser_use").setLevel(logging.CRITICAL)
14 | logging.getLogger("playwright").setLevel(logging.CRITICAL)
15 | 
16 | 
17 | def check_playwright_installation():
18 |     """
19 |     Check if Playwright is installed and properly set up.
20 |     Returns:
21 |         bool: True if Playwright is installed, False otherwise
22 |     """
23 |     try:
24 |         # Try to import playwright to check if it's installed
25 |         import playwright
26 | 
27 |         # Check if browsers are installed
28 |         try:
29 |             from playwright.sync_api import sync_playwright
30 | 
31 |             with sync_playwright() as p:
32 |                 # Try to launch a browser to verify installation
33 |                 browser = p.chromium.launch(headless=True)
34 |                 browser.close()
35 |             return True
36 |         except Exception as e:
37 |             if "Executable doesn't exist" in str(e):
38 |                 logger.error("Playwright browsers are not installed. Installing now...")
39 |                 try:
40 |                     # Redirect stdout and stderr to /dev/null to suppress progress bars
41 |                     with open(os.devnull, "w") as devnull:
42 |                         subprocess.run(
43 |                             [sys.executable, "-m", "playwright", "install", "chromium"],
44 |                             stdout=devnull,
45 |                             stderr=devnull,
46 |                             check=True,
47 |                         )
48 |                     logger.info("Playwright browsers installed successfully.")
49 |                     return True
50 |                 except subprocess.CalledProcessError:
51 |                     logger.error(
52 |                         "Failed to install Playwright browsers. Please run 'playwright install' manually."
53 |                     )
54 |                     return False
55 |             else:
56 |                 logger.error(f"Error checking Playwright installation: {e}")
57 |                 return False
58 |     except ImportError:
59 |         logger.error(
60 |             "Playwright is not installed. Please install it with 'pip install playwright'"
61 |         )
62 |         return False
63 | 
```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
 1 | 
 2 | name: Publish Python 🐍 distribution 📦 to PyPI
 3 | 
 4 | on: push
 5 | 
 6 | jobs:
 7 |   build:
 8 |     name: Build distribution 📦
 9 |     runs-on: ubuntu-latest
10 | 
11 |     steps:
12 |       - uses: actions/checkout@v4
13 |         with:
14 |           persist-credentials: false
15 |       - name: Set up Python
16 |         uses: actions/setup-python@v5
17 |         with:
18 |           python-version: "3.x"
19 |       - name: Install pypa/build
20 |         run: >-
21 |           python3 -m
22 |           pip install
23 |           build
24 |           --user
25 |       - name: Build a binary wheel and a source tarball
26 |         run: python3 -m build
27 |       - name: Store the distribution packages
28 |         uses: actions/upload-artifact@v4
29 |         with:
30 |           name: python-package-distributions
31 |           path: dist/
32 | 
33 |   publish-to-pypi:
34 |     name: >-
35 |       Publish Python 🐍 distribution 📦 to PyPI
36 |     if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
37 |     needs:
38 |       - build
39 |     runs-on: ubuntu-latest
40 |     environment:
41 |       name: pypi
42 |       url: https://pypi.org/p/mcp-browser-use
43 |     permissions:
44 |       id-token: write # IMPORTANT: mandatory for trusted publishing
45 | 
46 |     steps:
47 |       - name: Download all the dists
48 |         uses: actions/download-artifact@v4
49 |         with:
50 |           name: python-package-distributions
51 |           path: dist/
52 |       - name: Publish distribution 📦 to PyPI
53 |         uses: pypa/gh-action-pypi-publish@release/v1
54 | 
55 |   github-release:
56 |     name: >-
57 |       Sign the Python 🐍 distribution 📦 with Sigstore
58 |       and upload them to GitHub Release
59 |     needs:
60 |       - publish-to-pypi
61 |     runs-on: ubuntu-latest
62 | 
63 |     permissions:
64 |       contents: write # IMPORTANT: mandatory for making GitHub Releases
65 |       id-token: write # IMPORTANT: mandatory for sigstore
66 | 
67 |     steps:
68 |       - name: Download all the dists
69 |         uses: actions/download-artifact@v4
70 |         with:
71 |           name: python-package-distributions
72 |           path: dist/
73 |       - name: Sign the dists with Sigstore
74 |         uses: sigstore/[email protected]
75 |         with:
76 |           inputs: >-
77 |             ./dist/*.tar.gz
78 |             ./dist/*.whl
79 |       - name: Create GitHub Release
80 |         env:
81 |           GITHUB_TOKEN: ${{ github.token }}
82 |         run: >-
83 |           gh release create
84 |           "$GITHUB_REF_NAME"
85 |           --repo "$GITHUB_REPOSITORY"
86 |           --notes ""
87 |       - name: Upload artifact signatures to GitHub Release
88 |         env:
89 |           GITHUB_TOKEN: ${{ github.token }}
90 |         # Upload to GitHub Release using the `gh` CLI.
91 |         # `dist/` contains the built packages, and the
92 |         # sigstore-produced signatures and certificates.
93 |         run: >-
94 |           gh release upload
95 |           "$GITHUB_REF_NAME" dist/**
96 |           --repo "$GITHUB_REPOSITORY"
97 | 
```

--------------------------------------------------------------------------------
/src/mcp_browser_use/server.py:
--------------------------------------------------------------------------------

```python
  1 | # ruff: noqa: E402
  2 | 
  3 | import asyncio
  4 | import logging
  5 | import sys
  6 | from typing import Optional
  7 | 
  8 | logging.basicConfig(
  9 |     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
 10 | )
 11 | logger = logging.getLogger(__name__)
 12 | 
 13 | logging.getLogger("browser_use").setLevel(logging.CRITICAL)
 14 | logging.getLogger("playwright").setLevel(logging.CRITICAL)
 15 | 
 16 | import json
 17 | 
 18 | import markdownify
 19 | from browser_use.agent.message_manager.service import MessageManager
 20 | from browser_use.agent.prompts import AgentMessagePrompt, SystemPrompt
 21 | from browser_use.browser.browser import Browser, BrowserConfig
 22 | from browser_use.browser.context import BrowserContext
 23 | from mcp.server.fastmcp import FastMCP
 24 | 
 25 | from .utils import check_playwright_installation
 26 | 
 27 | mcp = FastMCP("browser_use")
 28 | 
 29 | browser: Optional[Browser] = None
 30 | browser_context: Optional[BrowserContext] = None
 31 | message_manager: Optional[MessageManager] = None
 32 | 
 33 | 
 34 | @mcp.tool()
 35 | async def initialize_browser(headless: bool = False, task: str = "") -> str:
 36 |     """Initialize a new browser instance.
 37 |     Args:
 38 |         headless: Whether to run browser in headless mode
 39 |         task: The task to be performed
 40 |     Returns:
 41 |         Status message
 42 |     """
 43 |     global browser, browser_context
 44 | 
 45 |     if browser:
 46 |         await close_browser()
 47 | 
 48 |     config = BrowserConfig(headless=headless)
 49 |     browser = Browser(config=config)
 50 |     browser_context = BrowserContext(browser=browser)
 51 | 
 52 |     system_prompt = SystemPrompt(
 53 |         action_description=(
 54 |             "Available actions: initialize_browser, close_browser, search_google, go_to_url, go_back, wait, click_element, input_text, "
 55 |             "switch_tab, open_tab, inspect_page, scroll_down, scroll_up, send_keys, scroll_to_text, "
 56 |             "get_dropdown_options, select_dropdown_option, validate_page, done"
 57 |         )
 58 |     ).get_system_message()
 59 | 
 60 |     browser_system_prompt = f"""
 61 |         {system_prompt.text()}
 62 |         Your ultimate task is: {task}.
 63 |         If you achieved your ultimate task, stop everything and use the done tool to complete the task.
 64 |         If not, continue as usual.
 65 |     """
 66 | 
 67 |     return browser_system_prompt
 68 | 
 69 | 
 70 | @mcp.tool()
 71 | async def close_browser() -> str:
 72 |     """Close the current browser instance.
 73 |     Returns:
 74 |         Status message
 75 |     """
 76 |     global browser, browser_context
 77 | 
 78 |     if browser_context:
 79 |         await browser_context.close()
 80 |         browser_context = None
 81 | 
 82 |     if browser:
 83 |         await browser.close()
 84 |         browser = None
 85 | 
 86 |     return "Browser closed successfully"
 87 | 
 88 | 
 89 | @mcp.tool()
 90 | async def search_google(query: str) -> str:
 91 |     """
 92 |     Search the query in Google in the current tab.
 93 |     Args:
 94 |         query (str): The search query to use in Google
 95 |     Returns:
 96 |         str: A message confirming the search was performed
 97 |     """
 98 |     page = await browser_context.get_current_page()
 99 |     await page.goto(f"https://www.google.com/search?q={query}&udm=14")
100 |     await page.wait_for_load_state()
101 |     return f'🔍 Searched for "{query}" in Google'
102 | 
103 | 
104 | @mcp.tool()
105 | async def go_to_url(url: str) -> str:
106 |     """
107 |     Navigate to URL in the current tab.
108 |     Args:
109 |         url (str): The URL to navigate to
110 |     Returns:
111 |         str: A message confirming navigation
112 |     """
113 |     page = await browser_context.get_current_page()
114 |     await page.goto(url)
115 |     await page.wait_for_load_state()
116 |     return f"🔗 Navigated to {url}"
117 | 
118 | 
119 | @mcp.tool()
120 | async def go_back() -> str:
121 |     """
122 |     Go back to the previous page.
123 |     Returns:
124 |         str: A message confirming navigation back
125 |     """
126 |     await browser_context.go_back()
127 |     return "🔙 Navigated back"
128 | 
129 | 
130 | @mcp.tool()
131 | async def wait(seconds: int = 3) -> str:
132 |     """
133 |     Wait for the specified number of seconds.
134 |     Args:
135 |         seconds (int, optional): Number of seconds to wait. Defaults to 3.
136 |     Returns:
137 |         str: A message confirming the wait
138 |     """
139 |     await asyncio.sleep(seconds)
140 |     return f"🕒 Waiting for {seconds} seconds"
141 | 
142 | 
143 | @mcp.tool()
144 | async def click_element(index: int) -> str:
145 |     """
146 |     Click the element with the specified index.
147 |     Args:
148 |         index (int): The index of the element to click
149 |     Returns:
150 |         str: A message describing the result of the click action
151 |     """
152 |     if index not in await browser_context.get_selector_map():
153 |         raise Exception(
154 |             f"Element with index {index} does not exist - retry or use alternative actions"
155 |         )
156 | 
157 |     element_node = await browser_context.get_dom_element_by_index(index)
158 |     session = await browser_context.get_session()
159 |     initial_pages = len(session.context.pages)
160 | 
161 |     # Check if element is a file uploader
162 |     if await browser_context.is_file_uploader(element_node):
163 |         return f"Index {index} - has an element which opens file upload dialog. Use a dedicated function for file uploads"
164 | 
165 |     try:
166 |         download_path = await browser_context._click_element_node(element_node)
167 |         if download_path:
168 |             msg = f"💾 Downloaded file to {download_path}"
169 |         else:
170 |             msg = f"🖱️ Clicked button with index {index}: {element_node.get_all_text_till_next_clickable_element(max_depth=2)}"
171 | 
172 |         # Handle new tab opening
173 |         if len(session.context.pages) > initial_pages:
174 |             msg += " - New tab opened - switching to it"
175 |             await browser_context.switch_to_tab(-1)
176 | 
177 |         return msg
178 |     except Exception as e:
179 |         if "Element not found" in str(e) or "Failed to click element" in str(e):
180 |             # Wait a moment and try again
181 |             await asyncio.sleep(1)
182 |             try:
183 |                 download_path = await browser_context._click_element_node(element_node)
184 |                 if download_path:
185 |                     msg = f"💾 Downloaded file to {download_path}"
186 |                 else:
187 |                     msg = f"🖱️ Clicked button with index {index}: {element_node.get_all_text_till_next_clickable_element(max_depth=2)}"
188 | 
189 |                 # Handle new tab opening
190 |                 if len(session.context.pages) > initial_pages:
191 |                     msg += " - New tab opened - switching to it"
192 |                     await browser_context.switch_to_tab(-1)
193 | 
194 |                 return msg
195 |             except Exception:
196 |                 raise Exception(
197 |                     f"Failed to click element with index {index} even after waiting: {str(e)}"
198 |                 )
199 |         else:
200 |             return f"Error clicking element with index {index}: {str(e)}. Call inspect_page() and try finding the element again."
201 | 
202 | 
203 | @mcp.tool()
204 | async def input_text(index: int, text: str, has_sensitive_data: bool = False) -> str:
205 |     """
206 |     Input text into an interactive element at the specified index.
207 |     Args:
208 |         index (int): The index of the element to input text into
209 |         text (str): The text to input
210 |         has_sensitive_data (bool, optional): Whether the text is sensitive data. Defaults to False.
211 |     Returns:
212 |         str: A message confirming the text input
213 |     """
214 |     if index not in await browser_context.get_selector_map():
215 |         raise Exception(
216 |             f"Element index {index} does not exist - retry or use alternative actions"
217 |         )
218 | 
219 |     element_node = await browser_context.get_dom_element_by_index(index)
220 |     await browser_context._input_text_element_node(element_node, text)
221 | 
222 |     if not has_sensitive_data:
223 |         return f"⌨️ Input {text} into index {index}"
224 |     else:
225 |         return f"⌨️ Input sensitive data into index {index}"
226 | 
227 | 
228 | @mcp.tool()
229 | async def switch_tab(page_id: int) -> str:
230 |     """
231 |     Switch to the tab with the specified page ID.
232 |     Args:
233 |         page_id (int): The ID of the page to switch to
234 |     Returns:
235 |         str: A message confirming the tab switch
236 |     """
237 |     await browser_context.switch_to_tab(page_id)
238 |     page = await browser_context.get_current_page()
239 |     await page.wait_for_load_state()
240 |     return f"🔄 Switched to tab {page_id}"
241 | 
242 | 
243 | @mcp.tool()
244 | async def open_tab(url: str) -> str:
245 |     """
246 |     Open a URL in a new tab.
247 |     Args:
248 |         url (str): The URL to open in the new tab
249 |     Returns:
250 |         str: A message confirming the new tab was opened
251 |     """
252 |     await browser_context.create_new_tab(url)
253 |     return f"🔗 Opened new tab with {url}"
254 | 
255 | 
256 | @mcp.tool()
257 | async def inspect_page() -> str:
258 |     """
259 |     Lists interactive elements and extracts content from the current page.
260 |     Returns:
261 |         str: A formatted string that lists all interactive elements (if any) along with the content.
262 |     """
263 |     # Get the current state to inspect interactive elements
264 |     state = await browser_context.get_state()
265 |     prompt_message = AgentMessagePrompt(
266 |         state,
267 |         include_attributes=["type", "role", "placeholder", "aria-label", "title"],
268 |     ).get_user_message(use_vision=False)
269 |     return prompt_message.content
270 | 
271 | 
272 | @mcp.tool()
273 | async def scroll_down(amount: int = None) -> str:
274 |     """
275 |     Scroll down the page by the specified amount.
276 |     Args:
277 |         amount (int, optional): Pixels to scroll down. If None, scrolls one page.
278 |     Returns:
279 |         str: A message confirming the scroll action
280 |     """
281 |     page = await browser_context.get_current_page()
282 |     if amount is not None:
283 |         await page.evaluate(f"window.scrollBy(0, {amount});")
284 |     else:
285 |         await page.evaluate("window.scrollBy(0, window.innerHeight);")
286 |     amount_str = f"{amount} pixels" if amount is not None else "one page"
287 |     return f"🔍 Scrolled down the page by {amount_str}"
288 | 
289 | 
290 | @mcp.tool()
291 | async def scroll_up(amount: int = None) -> str:
292 |     """
293 |     Scroll up the page by the specified amount.
294 |     Args:
295 |         amount (int, optional): Pixels to scroll up. If None, scrolls one page.
296 |     Returns:
297 |         str: A message confirming the scroll action
298 |     """
299 |     page = await browser_context.get_current_page()
300 |     if amount is not None:
301 |         await page.evaluate(f"window.scrollBy(0, -{amount});")
302 |     else:
303 |         await page.evaluate("window.scrollBy(0, -window.innerHeight);")
304 |     amount_str = f"{amount} pixels" if amount is not None else "one page"
305 |     return f"🔍 Scrolled up the page by {amount_str}"
306 | 
307 | 
308 | @mcp.tool()
309 | async def send_keys(keys: str) -> str:
310 |     """
311 |     Send keyboard keys or shortcuts to the current page.
312 |     Args:
313 |         keys (str): Keys to send, e.g. "Escape", "Enter", "Control+o"
314 |     Returns:
315 |         str: A message confirming the keys were sent
316 |     """
317 |     page = await browser_context.get_current_page()
318 |     try:
319 |         await page.keyboard.press(keys)
320 |     except Exception as e:
321 |         if "Unknown key" in str(e):
322 |             for key in keys:
323 |                 await page.keyboard.press(key)
324 |         else:
325 |             raise e
326 |     return f"⌨️ Sent keys: {keys}"
327 | 
328 | 
329 | @mcp.tool()
330 | async def scroll_to_text(text: str) -> str:
331 |     """
332 |     Scroll to an element containing the specified text.
333 |     Args:
334 |         text (str): The text to find and scroll to.
335 |     Returns:
336 |         str: A message confirming the scroll action or indicating failure.
337 |     """
338 |     page = await browser_context.get_current_page()
339 |     locators = [
340 |         page.get_by_text(text, exact=False),
341 |         page.locator(f"text={text}"),
342 |         page.locator(f"//*[contains(text(), '{text}')]"),
343 |     ]
344 |     for locator in locators:
345 |         try:
346 |             if await locator.count() > 0 and await locator.first.is_visible():
347 |                 await locator.first.scroll_into_view_if_needed()
348 |                 await asyncio.sleep(0.5)
349 |                 return f"🔍 Scrolled to text: {text}"
350 |         except Exception:
351 |             continue
352 |     return f"Text '{text}' not found or not visible on page"
353 | 
354 | 
355 | @mcp.tool()
356 | async def get_dropdown_options(index: int) -> str:
357 |     """
358 |     Get all options from a dropdown element.
359 |     Args:
360 |         index (int): The index of the dropdown element.
361 |     Returns:
362 |         str: A formatted string listing all dropdown options.
363 |     """
364 |     page = await browser_context.get_current_page()
365 |     selector_map = await browser_context.get_selector_map()
366 |     dom_element = selector_map[index]
367 |     all_options = []
368 |     for frame in page.frames:
369 |         try:
370 |             options = await frame.evaluate(
371 |                 """
372 |                 (xpath) => {
373 |                     const select = document.evaluate(xpath, document, null,
374 |                         XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
375 |                     if (!select) return null;
376 |                     return {
377 |                         options: Array.from(select.options).map(opt => ({
378 |                             text: opt.text,
379 |                             value: opt.value,
380 |                             index: opt.index
381 |                         })),
382 |                         id: select.id,
383 |                         name: select.name
384 |                     };
385 |                 }
386 |                 """,
387 |                 dom_element.xpath,
388 |             )
389 |             if options:
390 |                 formatted_options = []
391 |                 for opt in options["options"]:
392 |                     encoded_text = json.dumps(opt["text"])
393 |                     formatted_options.append(f'{opt["index"]}: text={encoded_text}')
394 |                 all_options.extend(formatted_options)
395 |         except Exception:
396 |             pass
397 |     if all_options:
398 |         msg = "\n".join(all_options)
399 |         msg += "\nUse the exact text string in select_dropdown_option"
400 |         return msg
401 |     else:
402 |         return "No options found in any frame for dropdown"
403 | 
404 | 
405 | @mcp.tool()
406 | async def select_dropdown_option(index: int, text: str) -> str:
407 |     """
408 |     Select an option from a dropdown by its text.
409 |     Args:
410 |         index (int): The index of the dropdown element.
411 |         text (str): The exact text of the option to select.
412 |     Returns:
413 |         str: A message confirming the option was selected.
414 |     """
415 |     page = await browser_context.get_current_page()
416 |     selector_map = await browser_context.get_selector_map()
417 |     dom_element = selector_map[index]
418 |     if dom_element.tag_name != "select":
419 |         return f"Cannot select option: Element with index {index} is a {dom_element.tag_name}, not a select"
420 |     for frame in page.frames:
421 |         try:
422 |             find_dropdown_js = """
423 |                 (xpath) => {
424 |                     try {
425 |                         const select = document.evaluate(xpath, document, null,
426 |                             XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
427 |                         if (!select) return null;
428 |                         if (select.tagName.toLowerCase() !== 'select') {
429 |                             return { error: `Found element but it's a ${select.tagName}, not a SELECT`, found: false };
430 |                         }
431 |                         return {
432 |                             id: select.id,
433 |                             name: select.name,
434 |                             found: true,
435 |                             tagName: select.tagName,
436 |                             optionCount: select.options.length,
437 |                             currentValue: select.value,
438 |                             availableOptions: Array.from(select.options).map(o => o.text.trim())
439 |                         };
440 |                     } catch (e) {
441 |                         return { error: e.toString(), found: false };
442 |                     }
443 |                 }
444 |             """
445 |             dropdown_info = await frame.evaluate(find_dropdown_js, dom_element.xpath)
446 |             if dropdown_info and dropdown_info.get("found"):
447 |                 selected_option_values = (
448 |                     await frame.locator("//" + dom_element.xpath)
449 |                     .nth(0)
450 |                     .select_option(label=text, timeout=1000)
451 |                 )
452 |                 return f"Selected option {text} with value {selected_option_values}"
453 |         except Exception:
454 |             pass
455 |     return f"Could not select option '{text}' in any frame"
456 | 
457 | 
458 | @mcp.tool()
459 | async def validate_page(expected_text: str = "") -> str:
460 |     """
461 |     Validate the current page state by extracting content and optionally checking for expected text.
462 |     Args:
463 |         expected_text (str): Optional text expected to be present on the page.
464 |     Returns:
465 |         str: A message indicating whether the expected text was found or showing an extracted snippet.
466 |     """
467 |     page = await browser_context.get_current_page()
468 |     content = markdownify.markdownify(await page.content())
469 |     if expected_text and expected_text.lower() in content.lower():
470 |         return (
471 |             f"✅ Validation successful: Expected text '{expected_text}' found on page."
472 |         )
473 |     elif expected_text:
474 |         return f"⚠ Validation warning: Expected text '{expected_text}' not found. Extracted snippet: {content[:200]}..."
475 |     else:
476 |         return f"Page content extracted:\n{content[:500]}..."
477 | 
478 | 
479 | @mcp.tool()
480 | async def done(success: bool = True, text: str = "") -> dict:
481 |     """
482 |     Complete the task with a success flag and optional text.
483 |     Returns:
484 |         dict: A dictionary indicating completion status.
485 |     """
486 |     return {"is_done": True, "success": success, "extracted_content": text}
487 | 
488 | 
489 | def main():
490 |     """Run the MCP server"""
491 |     if not check_playwright_installation():
492 |         logger.error("Playwright is not properly installed. Exiting.")
493 |         sys.exit(1)
494 | 
495 |     logger.info("Starting MCP server for browser-use")
496 |     mcp.run(transport="stdio")
497 | 
498 | 
499 | if __name__ == "__main__":
500 |     main()
501 | 
```