# 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 | [](https://pypi.org/project/mcp-browser-use/) [](https://pypi.org/project/mcp-browser-use/) [](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 |
```