├── .bandit.yml ├── .cursor └── mcp.json ├── .dockerignore ├── .env.example ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── mega-linter.yaml │ ├── python-publish.yml │ └── release.yaml ├── .gitignore ├── .mega-linter.yml ├── .pylintrc ├── .python-version ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── pyproject.toml ├── pyrightconfig.json ├── server ├── __init__.py ├── __main__.py └── server.py ├── src └── browser_use_mcp_server │ ├── __init__.py │ ├── cli.py │ └── server.py └── uv.lock /.bandit.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Bandit configuration file 3 | 4 | # Skip specific test IDs 5 | skips: [B104, B404, B603] 6 | 7 | # Plugin configs 8 | any_other_function_with_shell_equals_true: 9 | no_shell: [subprocess.Popen] -------------------------------------------------------------------------------- /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "browser-use-mcp-server": { 4 | "url": "http://localhost:8000/sse" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | Dockerfile 3 | .github 4 | **/__pycache__ 5 | **/*.pyc 6 | *.egg-info 7 | dist 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Path to Chrome/Chromium executable leave blank to use default playwright chromium 2 | CHROME_PATH= 3 | 4 | # OpenAI API key for OpenAI model access 5 | OPENAI_API_KEY=your-api-key-here 6 | 7 | # Set to true if you want api calls to wait for tasks to complete (default is false) 8 | PATIENT=false 9 | 10 | # Set to true if you want to disable anonymous telemetry (default is false) 11 | ANONYMIZED_TELEMETRY=false -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E501 4 | per-file-ignores = 5 | server/server.py:E501 6 | src/browser_use_mcp_server/cli.py:E501 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | Screenshots 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | ## Environment (please complete the following information): 31 | 32 | - OS: [e.g. macOS, Windows, Linux] 33 | - Version [e.g. 1.2.3] 34 | - Client [if applicable] 35 | - Version [e.g. 1.2.3] 36 | 37 | ## Additional context 38 | 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ## Describe alternatives you've considered 18 | 19 | A clear and concise description of 20 | any alternative solutions or features you've considered. 21 | 22 | ## Additional context 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the changes and which issue is fixed. Please also 4 | include relevant motivation and context. 5 | 6 | Fixes # (issue) 7 | 8 | ## Type of change 9 | 10 | Please delete options that are not relevant. 11 | 12 | - [ ] Bug fix (non-breaking change which fixes an issue) 13 | - [ ] New feature (non-breaking change which adds functionality) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to 15 | not work as expected) 16 | - [ ] Documentation update 17 | 18 | ## How Has This Been Tested? 19 | 20 | Please describe the tests that you ran to verify your changes. Provide 21 | instructions so we can reproduce. 22 | 23 | ## Checklist: 24 | 25 | - [ ] My code follows the style guidelines of this project 26 | - [ ] I have performed a self-review of my code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have made corresponding changes to the documentation 29 | - [ ] My changes generate no new warnings 30 | - [ ] I have added tests that prove my fix is effective or that my feature works 31 | - [ ] New and existing unit tests pass locally with my changes (if applicable) 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | release: 9 | types: [published] # Trigger when a release is published 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref_name }}-${{ 13 | github.event.pull_request.number || github.sha }} 14 | cancel-in-progress: true 15 | 16 | env: 17 | PYTHON_VERSION: "3.13" 18 | REGISTRY: ghcr.io 19 | IMAGE_NAME: ${{ github.repository }} 20 | 21 | jobs: 22 | lint: 23 | timeout-minutes: 10 24 | name: "lint" 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 1 30 | 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: 3.13 34 | 35 | - name: "Install uv" 36 | uses: astral-sh/setup-uv@v5 37 | 38 | - name: "Python format" 39 | run: uvx ruff format --diff . 40 | 41 | - name: "Python lint" 42 | run: uvx ruff check . 43 | 44 | build-and-publish: 45 | runs-on: ubuntu-latest 46 | 47 | permissions: 48 | contents: read 49 | packages: write 50 | id-token: write 51 | 52 | steps: 53 | - name: Checkout repository 54 | uses: actions/checkout@v4 55 | 56 | - name: Log in to the Container registry 57 | uses: docker/login-action@v3 58 | with: 59 | registry: ${{ env.REGISTRY }} 60 | username: ${{ github.actor }} 61 | password: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | - name: Extract metadata (tags, labels) for Docker 64 | id: meta 65 | uses: docker/metadata-action@v5 66 | with: 67 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 68 | 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | 72 | - name: Build and push Docker image 73 | id: push 74 | uses: docker/build-push-action@v6 75 | with: 76 | context: ./ 77 | file: ./Dockerfile 78 | platforms: linux/amd64 79 | push: true 80 | tags: ${{ steps.meta.outputs.tags }} 81 | labels: ${{ steps.meta.outputs.labels }} 82 | build-args: | 83 | VNC_PASSWORD=${{ secrets.VNC_PASSWORD }} 84 | cache-from: type=gha 85 | cache-to: type=gha,mode=max 86 | -------------------------------------------------------------------------------- /.github/workflows/mega-linter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # MegaLinter GitHub Action configuration file 3 | # More info at https://megalinter.io 4 | name: MegaLinter 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | permissions: read-all 13 | 14 | env: # Comment env block if you don't want to apply fixes 15 | # Apply linter fixes configuration 16 | APPLY_FIXES: all # When active, APPLY_FIXES must also be defined as environment variable (in github/workflows/mega-linter.yml or other CI tool) 17 | APPLY_FIXES_EVENT: none # Decide which event triggers application of fixes in a commit or a PR (pull_request, push, all) 18 | APPLY_FIXES_MODE: commit # If APPLY_FIXES is used, defines if the fixes are directly committed (commit) or posted in a PR (pull_request) 19 | 20 | concurrency: 21 | group: ${{ github.ref }}-${{ github.workflow }} 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | megalinter: 26 | name: MegaLinter 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: read 30 | issues: write 31 | pull-requests: write 32 | steps: 33 | # Git Checkout 34 | - name: Checkout Code 35 | uses: actions/checkout@v4 36 | with: 37 | token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} 38 | fetch-depth: 0 # If you use VALIDATE_ALL_CODEBASE = true, you can remove this line to improve performances 39 | # MegaLinter 40 | - name: MegaLinter 41 | id: ml 42 | # deployed v8.3.0, https://github.com/oxsecurity/megalinter/releases/tag/v8.3.0 43 | uses: oxsecurity/megalinter@1fc052d03c7a43c78fe0fee19c9d648b749e0c01 44 | env: 45 | # All available variables are described in documentation 46 | # https://megalinter.io/configuration/ 47 | VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} # Validates all source when push on main, else just the git diff with main. Override with true if you always want to lint all sources 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | # Upload MegaLinter artifacts 50 | - name: Archive production artifacts 51 | if: success() || failure() 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: MegaLinter reports 55 | path: | 56 | megalinter-reports 57 | mega-linter.log 58 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Upload Python Package 3 | 4 | on: 5 | release: 6 | types: [published] 7 | workflow_dispatch: 8 | inputs: 9 | commit: 10 | description: "Commit to build from" 11 | required: true 12 | default: "main" 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | release-build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up uv and Python 27 | uses: astral-sh/setup-uv@v5 28 | with: 29 | enable-cache: true 30 | python-version: "3.13" 31 | cache-dependency-glob: "pyproject.toml" 32 | 33 | - name: Build 34 | run: uv build 35 | 36 | - name: Upload build artifacts 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: pypi-dists 40 | path: dist/ 41 | 42 | pypi-publish: 43 | runs-on: ubuntu-latest 44 | needs: 45 | - release-build 46 | permissions: 47 | # IMPORTANT: this permission is mandatory for trusted publishing 48 | id-token: write 49 | 50 | # Dedicated environments with protections for publishing are strongly recommended. 51 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 52 | environment: 53 | name: pypi 54 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 55 | # url: https://pypi.org/p/YOURPROJECT 56 | # 57 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 58 | # ALTERNATIVE: exactly, uncomment the following line instead: 59 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 60 | 61 | steps: 62 | - name: Retrieve release distributions 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: pypi-dists 66 | path: dist/ 67 | 68 | - name: Publish release distributions to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | with: 71 | packages-dir: dist/ 72 | 73 | github-release: 74 | name: >- 75 | Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub 76 | Release 77 | needs: 78 | - pypi-publish 79 | runs-on: ubuntu-latest 80 | 81 | permissions: 82 | contents: write # IMPORTANT: mandatory for making GitHub Releases 83 | id-token: write # IMPORTANT: mandatory for sigstore 84 | 85 | steps: 86 | - name: Download all the dists 87 | uses: actions/download-artifact@v4 88 | with: 89 | name: pypi-dists 90 | path: dist/ 91 | - name: Sign the dists with Sigstore 92 | uses: sigstore/gh-action-sigstore-python@v3.0.0 93 | with: 94 | inputs: >- 95 | ./dist/*.tar.gz ./dist/*.whl 96 | - name: Upload artifact signatures to GitHub Release 97 | env: 98 | GITHUB_TOKEN: ${{ github.token }} 99 | # Upload to GitHub Release using the `gh` CLI. 100 | # `dist/` contains the built packages, and the 101 | # sigstore-produced signatures and certificates. 102 | # --clobber overwrites existing assets 103 | run: >- 104 | gh release upload "$GITHUB_REF_NAME" dist/** --repo 105 | "$GITHUB_REPOSITORY" --clobber 106 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tag and Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v4 26 | 27 | - name: Run semantic-release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE }} 30 | run: npx semantic-release --branches main --plugins "@semantic-release/commit-analyzer,@semantic-release/release-notes-generator,@semantic-release/github" 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | **/__pycache__ 3 | **/*.pyc 4 | .env 5 | *.egg-info 6 | dist 7 | -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration file for MegaLinter 3 | # See all available variables at https://megalinter.io/configuration/ and in linters documentation 4 | 5 | DISABLE_LINTERS: 6 | - SPELL_CSPELL 7 | - SPELL_LYCHEE 8 | 9 | DISABLE_ERRORS_LINTERS: 10 | - COPYPASTE_JSCPD 11 | - DOCKERFILE_HADOLINT 12 | - MARKDOWN_MARKDOWN_LINK_CHECK 13 | - REPOSITORY_CHECKOV 14 | - REPOSITORY_DEVSKIM 15 | - REPOSITORY_KICS 16 | - REPOSITORY_TRIVY 17 | 18 | EMAIL_REPORTER: false 19 | FILEIO_REPORTER: false 20 | MARKDOWN_SUMMARY_REPORTER: true 21 | SHOW_ELAPSED_TIME: true -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # Python version 3 | py-version = 3.11 4 | 5 | # Disable specific messages 6 | disable= 7 | C0301, # Line too long 8 | R0402, # Use 'from mcp import types' instead 9 | W1203, # Use lazy % formatting in logging functions 10 | R0913, # Too many arguments 11 | R0917, # Too many positional arguments 12 | R0914, # Too many local variables 13 | W0718, # Catching too general exception Exception 14 | R0915, # Too many statements 15 | W0613, # Unused argument 16 | R1705, # Unnecessary "elif" after "return" 17 | R0912, # Too many branches 18 | W0621, # Redefining name from outer scope 19 | W0404, # Reimport 20 | C0415, # Import outside toplevel 21 | W0212, # Access to a protected member 22 | W0107, # Unnecessary pass statement 23 | R0801, # Similar lines in files 24 | import-error, 25 | no-value-for-parameter, 26 | logging-fstring-interpolation, 27 | protected-access, 28 | redefined-outer-name, 29 | reimported 30 | 31 | # Add files or directories to the blacklist 32 | ignore=.git,__pycache__,.venv,dist,build 33 | 34 | # Use multiple processes to speed up Pylint 35 | jobs=4 36 | 37 | [FORMAT] 38 | # Maximum number of characters on a single line 39 | max-line-length=120 40 | 41 | # Maximum number of lines in a module 42 | max-module-lines=300 43 | 44 | [MESSAGES CONTROL] 45 | # Only show warnings with the listed confidence levels 46 | confidence=HIGH,CONTROL_FLOW 47 | 48 | [DESIGN] 49 | # Maximum number of arguments for function / method 50 | max-args=10 51 | 52 | # Maximum number of locals for function / method 53 | max-locals=30 54 | 55 | # Maximum number of statements in function / method body 56 | max-statements=60 57 | 58 | # Maximum number of branch for function / method body 59 | max-branches=15 -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at [info at 63 | cobrowser.xyz]. All complaints will be reviewed and investigated promptly and 64 | fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to browser-use MCP Server 2 | 3 | First off, thank you for considering contributing to browser-use MCP Server! 4 | This project is released under the MIT License, which means your contributions 5 | will also be covered under the same permissive license. 6 | 7 | ### Table of Contents 8 | 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Getting Started](#getting-started) 11 | - [How to Contribute](#how-to-contribute) 12 | - [Guidelines for Non-Code Contributions](#guidelines-for-non-code-contributions) 13 | - [Reporting Bugs](#reporting-bugs) 14 | - [Suggesting Enhancements](#suggesting-enhancements) 15 | - [Pull Requests](#pull-requests) 16 | - [Development Process](#development-process) 17 | - [License](#license) 18 | 19 | ## Code of Conduct 20 | 21 | We have adopted a Code of Conduct that we expect project participants to adhere 22 | to. Please read [the full text](CODE_OF_CONDUCT.md) so that you can understand 23 | what actions will and will not be tolerated. 24 | 25 | ## Getting Started 26 | 27 | ### Fork-based workflow (recommended as a playground) 28 | 29 | 1. Fork the repository 30 | 2. Clone your fork: 31 | `git clone https://github.com/your-username/browser-use-mcp-server.git` 32 | 3. Create a new branch: `git checkout -b feature/your-feature-name` 33 | 4. Make your changes 34 | 5. Push to your fork: `git push origin feature/your-feature-name` 35 | 6. Open a Pull Request 36 | 37 | ### Direct repository workflow (for contributors) 38 | 39 | 1. Clone the repository directly: 40 | `git clone https://github.com/co-browser/browser-use-mcp-server.git` 41 | 2. Create a new branch: `git checkout -b feature/your-feature-name` 42 | 3. Make your changes 43 | 4. Push to the repository: `git push origin feature/your-feature-name` 44 | 5. Open a Pull Request 45 | 46 | If you're interested in being contributor, please reach out to the maintainers 47 | after making a few successful contributions via issues and pull requests. 48 | 49 | ## How to Contribute 50 | 51 | ### Guidelines for Non-Code Contributions 52 | 53 | We appreciate your attention to detail. However, minor fixes like typos or 54 | grammar corrections should not be submitted individually. Instead, create an 55 | issue noting these corrections, and we'll batch them into larger updates. 56 | 57 | ### Reporting Bugs 58 | 59 | We use GitHub issues to track bugs. Before creating a bug report: 60 | 61 | - Search existing 62 | [Issues](https://github.com/co-browser/browser-use-mcp-server/issues) to 63 | ensure it hasn't already been reported 64 | - If you find a closed issue that seems to address your problem, open a new 65 | issue and include a link to the original 66 | 67 | When submitting a bug report, please use our bug report template and include as 68 | much detail as possible. 69 | 70 | ### Suggesting Enhancements 71 | 72 | Enhancement suggestions are tracked through GitHub issues. Please use our 73 | feature request template when suggesting enhancements. 74 | 75 | ### Pull Requests 76 | 77 | - Follow our pull request template 78 | - Include screenshots and animated GIFs in your pull request whenever possible 79 | - Follow our coding conventions and style guidelines 80 | - Write meaningful commit messages 81 | - Update documentation as needed 82 | - Add tests for new features 83 | - Pull requests undergo automated checks, including build and linting 84 | 85 | ## Development Process 86 | 87 | 1. Pick an issue to work on or create a new one 88 | 2. Comment on the issue to let others know you're working on it 89 | 3. Create a branch with a descriptive name 90 | 4. Write your code following our style guidelines 91 | 5. Add tests for new functionality 92 | 6. Update documentation as needed 93 | 7. Submit a pull request 94 | 8. Respond to code review feedback 95 | 96 | ## License 97 | 98 | By contributing to browser-use MCP Server, you agree that your contributions 99 | will be licensed under the MIT License. See [LICENSE](LICENSE) for details. 100 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:bookworm-slim AS builder 2 | 3 | ENV UV_COMPILE_BYTECODE=1 \ 4 | UV_LINK_MODE=copy \ 5 | UV_PYTHON_INSTALL_DIR=/python \ 6 | UV_PYTHON_PREFERENCE=only-managed 7 | 8 | # Install build dependencies and clean up in the same layer 9 | RUN apt-get update -y && \ 10 | apt-get install --no-install-recommends -y clang git && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | # Install Python before the project for caching 14 | RUN uv python install 3.13 15 | 16 | WORKDIR /app 17 | RUN --mount=type=cache,target=/root/.cache/uv \ 18 | --mount=type=bind,source=uv.lock,target=uv.lock \ 19 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 20 | uv sync --frozen --no-install-project --no-dev 21 | COPY . /app 22 | RUN --mount=type=cache,target=/root/.cache/uv \ 23 | uv sync --frozen --no-dev 24 | 25 | FROM debian:bookworm-slim AS runtime 26 | 27 | # VNC password will be read from Docker secrets or fallback to default 28 | # Create a fallback default password file 29 | RUN mkdir -p /run/secrets && \ 30 | echo "browser-use" > /run/secrets/vnc_password_default 31 | 32 | # Install required packages including Chromium and clean up in the same layer 33 | RUN apt-get update && \ 34 | apt-get install --no-install-recommends -y \ 35 | xfce4 \ 36 | xfce4-terminal \ 37 | dbus-x11 \ 38 | tigervnc-standalone-server \ 39 | tigervnc-tools \ 40 | nodejs \ 41 | npm \ 42 | fonts-freefont-ttf \ 43 | fonts-ipafont-gothic \ 44 | fonts-wqy-zenhei \ 45 | fonts-thai-tlwg \ 46 | fonts-kacst \ 47 | fonts-symbola \ 48 | fonts-noto-color-emoji && \ 49 | npm i -g proxy-login-automator && \ 50 | apt-get clean && \ 51 | rm -rf /var/lib/apt/lists/* && \ 52 | rm -rf /var/cache/apt/* 53 | 54 | # Copy only necessary files from builder 55 | COPY --from=builder /python /python 56 | COPY --from=builder /app /app 57 | # Set proper permissions 58 | RUN chmod -R 755 /python /app 59 | 60 | ENV ANONYMIZED_TELEMETRY=false \ 61 | PATH="/app/.venv/bin:$PATH" \ 62 | DISPLAY=:0 \ 63 | CHROME_BIN=/usr/bin/chromium \ 64 | CHROMIUM_FLAGS="--no-sandbox --headless --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage" 65 | 66 | # Combine VNC setup commands to reduce layers 67 | RUN mkdir -p ~/.vnc && \ 68 | printf '#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\nstartxfce4' > /root/.vnc/xstartup && \ 69 | chmod +x /root/.vnc/xstartup && \ 70 | printf '#!/bin/bash\n\n# Use Docker secret for VNC password if available, else fallback to default\nif [ -f "/run/secrets/vnc_password" ]; then\n cat /run/secrets/vnc_password | vncpasswd -f > /root/.vnc/passwd\nelse\n cat /run/secrets/vnc_password_default | vncpasswd -f > /root/.vnc/passwd\nfi\n\nchmod 600 /root/.vnc/passwd\nvncserver -depth 24 -geometry 1920x1080 -localhost no -PasswordFile /root/.vnc/passwd :0\nproxy-login-automator\npython /app/server --port 8000' > /app/boot.sh && \ 71 | chmod +x /app/boot.sh 72 | 73 | RUN playwright install --with-deps --no-shell chromium 74 | 75 | EXPOSE 8000 76 | 77 | ENTRYPOINT ["/bin/bash", "/app/boot.sh"] 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 cobrowser.xyz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # browser-use-mcp-server 2 | 3 |
4 | 5 | [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/cobrowser.svg?style=social&label=Follow%20%40cobrowser)](https://x.com/cobrowser) 6 | [![Discord](https://img.shields.io/discord/1351569878116470928?logo=discord&logoColor=white&label=discord&color=white)](https://discord.gg/gw9UpFUhyY) 7 | [![PyPI version](https://badge.fury.io/py/browser-use-mcp-server.svg)](https://badge.fury.io/py/browser-use-mcp-server) 8 | 9 | **An MCP server that enables AI agents to control web browsers using 10 | [browser-use](https://github.com/browser-use/browser-use).** 11 | 12 | > **🔗 Managing multiple MCP servers?** Simplify your development workflow with [agent-browser](https://github.com/co-browser/agent-browser) 13 | 14 |
15 | 16 | ## Prerequisites 17 | 18 | - [uv](https://github.com/astral-sh/uv) - Fast Python package manager 19 | - [Playwright](https://playwright.dev/) - Browser automation 20 | - [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) - Required for stdio mode 21 | 22 | ```bash 23 | # Install prerequisites 24 | curl -LsSf https://astral.sh/uv/install.sh | sh 25 | uv tool install mcp-proxy 26 | uv tool update-shell 27 | ``` 28 | 29 | ## Environment 30 | 31 | Create a `.env` file: 32 | 33 | ```bash 34 | OPENAI_API_KEY=your-api-key 35 | CHROME_PATH=optional/path/to/chrome 36 | PATIENT=false # Set to true if API calls should wait for task completion 37 | ``` 38 | 39 | ## Installation 40 | 41 | ```bash 42 | # Install dependencies 43 | uv sync 44 | uv pip install playwright 45 | uv run playwright install --with-deps --no-shell chromium 46 | ``` 47 | 48 | ## Usage 49 | 50 | ### SSE Mode 51 | 52 | ```bash 53 | # Run directly from source 54 | uv run server --port 8000 55 | ``` 56 | 57 | ### stdio Mode 58 | 59 | ```bash 60 | # 1. Build and install globally 61 | uv build 62 | uv tool uninstall browser-use-mcp-server 2>/dev/null || true 63 | uv tool install dist/browser_use_mcp_server-*.whl 64 | 65 | # 2. Run with stdio transport 66 | browser-use-mcp-server run server --port 8000 --stdio --proxy-port 9000 67 | ``` 68 | 69 | ## Client Configuration 70 | 71 | ### SSE Mode Client Configuration 72 | 73 | ```json 74 | { 75 | "mcpServers": { 76 | "browser-use-mcp-server": { 77 | "url": "http://localhost:8000/sse" 78 | } 79 | } 80 | } 81 | ``` 82 | 83 | ### stdio Mode Client Configuration 84 | 85 | ```json 86 | { 87 | "mcpServers": { 88 | "browser-server": { 89 | "command": "browser-use-mcp-server", 90 | "args": [ 91 | "run", 92 | "server", 93 | "--port", 94 | "8000", 95 | "--stdio", 96 | "--proxy-port", 97 | "9000" 98 | ], 99 | "env": { 100 | "OPENAI_API_KEY": "your-api-key" 101 | } 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | ### Config Locations 108 | 109 | | Client | Configuration Path | 110 | | ---------------- | ----------------------------------------------------------------- | 111 | | Cursor | `./.cursor/mcp.json` | 112 | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | 113 | | Claude (Mac) | `~/Library/Application Support/Claude/claude_desktop_config.json` | 114 | | Claude (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` | 115 | 116 | ## Features 117 | 118 | - [x] **Browser Automation**: Control browsers through AI agents 119 | - [x] **Dual Transport**: Support for both SSE and stdio protocols 120 | - [x] **VNC Streaming**: Watch browser automation in real-time 121 | - [x] **Async Tasks**: Execute browser operations asynchronously 122 | 123 | ## Local Development 124 | 125 | To develop and test the package locally: 126 | 127 | 1. Build a distributable wheel: 128 | 129 | ```bash 130 | # From the project root directory 131 | uv build 132 | ``` 133 | 134 | 2. Install it as a global tool: 135 | 136 | ```bash 137 | uv tool uninstall browser-use-mcp-server 2>/dev/null || true 138 | uv tool install dist/browser_use_mcp_server-*.whl 139 | ``` 140 | 141 | 3. Run from any directory: 142 | 143 | ```bash 144 | # Set your OpenAI API key for the current session 145 | export OPENAI_API_KEY=your-api-key-here 146 | 147 | # Or provide it inline for a one-time run 148 | OPENAI_API_KEY=your-api-key-here browser-use-mcp-server run server --port 8000 --stdio --proxy-port 9000 149 | ``` 150 | 151 | 4. After making changes, rebuild and reinstall: 152 | ```bash 153 | uv build 154 | uv tool uninstall browser-use-mcp-server 155 | uv tool install dist/browser_use_mcp_server-*.whl 156 | ``` 157 | 158 | ## Docker 159 | 160 | Using Docker provides a consistent and isolated environment for running the server. 161 | 162 | ```bash 163 | # Build the Docker image 164 | docker build -t browser-use-mcp-server . 165 | 166 | # Run the container with the default VNC password ("browser-use") 167 | # --rm ensures the container is automatically removed when it stops 168 | # -p 8000:8000 maps the server port 169 | # -p 5900:5900 maps the VNC port 170 | docker run --rm -p8000:8000 -p5900:5900 browser-use-mcp-server 171 | 172 | # Run with a custom VNC password read from a file 173 | # Create a file (e.g., vnc_password.txt) containing only your desired password 174 | echo "your-secure-password" > vnc_password.txt 175 | # Mount the password file as a secret inside the container 176 | docker run --rm -p8000:8000 -p5900:5900 \ 177 | -v $(pwd)/vnc_password.txt:/run/secrets/vnc_password:ro \ 178 | browser-use-mcp-server 179 | ``` 180 | 181 | *Note: The `:ro` flag in the volume mount (`-v`) makes the password file read-only inside the container for added security.* 182 | 183 | ### VNC Viewer 184 | 185 | ```bash 186 | # Browser-based viewer 187 | git clone https://github.com/novnc/noVNC 188 | cd noVNC 189 | ./utils/novnc_proxy --vnc localhost:5900 190 | ``` 191 | 192 | Default password: `browser-use` (unless overridden using the custom password method) 193 | 194 |
195 | VNC Screenshot 196 |

197 | VNC Screenshot 198 |
199 | 200 | ## Example 201 | 202 | Try asking your AI: 203 | 204 | ```text 205 | open https://news.ycombinator.com and return the top ranked article 206 | ``` 207 | 208 | ## Support 209 | 210 | For issues or inquiries: [cobrowser.xyz](https://cobrowser.xyz) 211 | 212 | ## Star History 213 | 214 |
215 | 216 | 217 | 218 | Star History Chart 219 | 220 |
221 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "browser-use-mcp-server" 3 | dynamic = ["version"] 4 | description = "MCP browser-use server library" 5 | readme = "README.md" 6 | requires-python = ">=3.11,<4.0" 7 | license = {text = "MIT"} 8 | authors = [ 9 | {name = "Cobrowser Team"} 10 | ] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "Operating System :: OS Independent", 14 | ] 15 | dependencies = [ 16 | "asyncio>=3.4.3", 17 | "browser-use>=0.1.40", 18 | "click>=8.1.8", 19 | "httpx>=0.28.1", 20 | "langchain-openai>=0.3.1", 21 | "mcp>=1.3.0", 22 | "pydantic>=2.10.6", 23 | "anyio", 24 | "python-dotenv", 25 | "python-json-logger>=2.0.7", 26 | "starlette", 27 | "uvicorn", 28 | "playwright>=1.50.0", 29 | ] 30 | 31 | [project.optional-dependencies] 32 | # Dependencies for running tests 33 | test = [ 34 | "pytest>=7.0.0", 35 | "pytest-asyncio>=0.21.0", 36 | "pytest-cov>=4.1.0", 37 | ] 38 | # Dependencies for development (includes test dependencies) 39 | dev = [ 40 | "browser-use-mcp-server[test]", 41 | "black>=23.0.0", 42 | "isort>=5.12.0", 43 | "mypy>=1.0.0", 44 | "ruff>=0.5.5", 45 | ] 46 | 47 | [project.urls] 48 | "Homepage" = "https://github.com/cobrowser/browser-use-mcp-server" 49 | "Bug Tracker" = "https://github.com/cobrowser/browser-use-mcp-server/issues" 50 | 51 | [tool.pytest.ini_options] 52 | testpaths = ["tests"] 53 | python_files = "test_*.py" 54 | asyncio_mode = "auto" 55 | asyncio_default_fixture_loop_scope = "function" 56 | 57 | [tool.black] 58 | line-length = 88 59 | target-version = ["py311"] 60 | 61 | [tool.isort] 62 | profile = "black" 63 | line_length = 88 64 | 65 | [tool.mypy] 66 | python_version = "3.11" 67 | warn_return_any = true 68 | warn_unused_configs = true 69 | disallow_untyped_defs = true 70 | disallow_incomplete_defs = true 71 | 72 | [project.scripts] 73 | browser-use-mcp-server = "browser_use_mcp_server.cli:cli" 74 | 75 | [build-system] 76 | requires = ["hatchling", "uv-dynamic-versioning"] 77 | build-backend = "hatchling.build" 78 | 79 | [tool.hatch.build] 80 | include = ["src/browser_use_mcp_server", "server"] 81 | 82 | [tool.hatch.build.targets.wheel] 83 | packages = ["src/browser_use_mcp_server", "server"] 84 | 85 | [tool.hatch.version] 86 | source = "uv-dynamic-versioning" 87 | 88 | [tool.uv-dynamic-versioning] 89 | vcs = "git" 90 | style = "pep440" 91 | bump = true 92 | 93 | [tool.ruff] 94 | line-length = 88 95 | target-version = "py311" 96 | 97 | [tool.ruff.lint] 98 | # Enable common Pyflakes, pycodestyle, and isort rules 99 | select = ["E", "F", "W", "I"] 100 | # Ignore line length violations in comments, docstrings, and string literals 101 | extend-ignore = ["E501"] 102 | 103 | # Exclude string literals and comments from line length checks 104 | [tool.ruff.lint.per-file-ignores] 105 | "server/server.py" = ["E501"] 106 | "src/browser_use_mcp_server/cli.py" = ["E501"] 107 | 108 | [tool.ruff.format] 109 | # Use black-compatible formatting 110 | quote-style = "double" 111 | -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "reportMissingImports": false, 3 | "reportMissingModuleSource": false, 4 | "reportOptionalMemberAccess": false, 5 | "reportAttributeAccessIssue": false, 6 | "reportCallIssue": false, 7 | "reportFunctionMemberAccess": false 8 | } -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Browser-Use MCP Server core implementation. 3 | 4 | This package provides the core implementation of the MCP server for browser automation. 5 | """ 6 | 7 | from .server import ( 8 | CONFIG, 9 | Server, 10 | cleanup_old_tasks, 11 | create_browser_context_for_task, 12 | create_mcp_server, 13 | init_configuration, 14 | main, 15 | run_browser_task_async, 16 | task_store, 17 | ) 18 | 19 | __all__ = [ 20 | "Server", 21 | "main", 22 | "create_browser_context_for_task", 23 | "run_browser_task_async", 24 | "cleanup_old_tasks", 25 | "create_mcp_server", 26 | "init_configuration", 27 | "CONFIG", 28 | "task_store", 29 | ] 30 | -------------------------------------------------------------------------------- /server/__main__.py: -------------------------------------------------------------------------------- 1 | """Server entry point.""" 2 | 3 | import sys 4 | 5 | from server import main 6 | 7 | sys.exit(main()) 8 | -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Browser Use MCP Server 3 | 4 | This module implements an MCP (Model-Control-Protocol) server for browser automation 5 | using the browser_use library. It provides functionality to interact with a browser instance 6 | via an async task queue, allowing for long-running browser tasks to be executed asynchronously 7 | while providing status updates and results. 8 | 9 | The server supports Server-Sent Events (SSE) for web-based interfaces. 10 | """ 11 | 12 | # Standard library imports 13 | import asyncio 14 | import json 15 | import logging 16 | import os 17 | import sys 18 | 19 | # Set up SSE transport 20 | import threading 21 | import time 22 | import traceback 23 | import uuid 24 | from datetime import datetime 25 | from typing import Any, Dict, Optional, Tuple, Union 26 | 27 | # Third-party imports 28 | import click 29 | import mcp.types as types 30 | import uvicorn 31 | 32 | # Browser-use library imports 33 | from browser_use import Agent 34 | from browser_use.browser.browser import Browser, BrowserConfig 35 | from browser_use.browser.context import BrowserContext, BrowserContextConfig 36 | from dotenv import load_dotenv 37 | from langchain_core.language_models import BaseLanguageModel 38 | 39 | # LLM provider 40 | from langchain_openai import ChatOpenAI 41 | 42 | # MCP server components 43 | from mcp.server import Server 44 | from mcp.server.sse import SseServerTransport 45 | from pythonjsonlogger import jsonlogger 46 | from starlette.applications import Starlette 47 | from starlette.routing import Mount, Route 48 | 49 | # Configure logging 50 | logger = logging.getLogger() 51 | logger.handlers = [] # Remove any existing handlers 52 | handler = logging.StreamHandler(sys.stderr) 53 | formatter = jsonlogger.JsonFormatter( 54 | '{"time":"%(asctime)s","level":"%(levelname)s","name":"%(name)s","message":"%(message)s"}' 55 | ) 56 | handler.setFormatter(formatter) 57 | logger.addHandler(handler) 58 | logger.setLevel(logging.INFO) 59 | 60 | # Ensure uvicorn also logs to stderr in JSON format 61 | uvicorn_logger = logging.getLogger("uvicorn") 62 | uvicorn_logger.handlers = [] 63 | uvicorn_logger.addHandler(handler) 64 | 65 | # Ensure all other loggers use the same format 66 | logging.getLogger("browser_use").addHandler(handler) 67 | logging.getLogger("playwright").addHandler(handler) 68 | logging.getLogger("mcp").addHandler(handler) 69 | 70 | # Load environment variables 71 | load_dotenv() 72 | 73 | 74 | def parse_bool_env(env_var: str, default: bool = False) -> bool: 75 | """ 76 | Parse a boolean environment variable. 77 | 78 | Args: 79 | env_var: The environment variable name 80 | default: Default value if not set 81 | 82 | Returns: 83 | Boolean value of the environment variable 84 | """ 85 | value = os.environ.get(env_var) 86 | if value is None: 87 | return default 88 | 89 | # Consider various representations of boolean values 90 | return value.lower() in ("true", "yes", "1", "y", "on") 91 | 92 | 93 | def init_configuration() -> Dict[str, Any]: 94 | """ 95 | Initialize configuration from environment variables with defaults. 96 | 97 | Returns: 98 | Dictionary containing all configuration parameters 99 | """ 100 | config = { 101 | # Browser window settings 102 | "DEFAULT_WINDOW_WIDTH": int(os.environ.get("BROWSER_WINDOW_WIDTH", 1280)), 103 | "DEFAULT_WINDOW_HEIGHT": int(os.environ.get("BROWSER_WINDOW_HEIGHT", 1100)), 104 | # Browser config settings 105 | "DEFAULT_LOCALE": os.environ.get("BROWSER_LOCALE", "en-US"), 106 | "DEFAULT_USER_AGENT": os.environ.get( 107 | "BROWSER_USER_AGENT", 108 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", 109 | ), 110 | # Task settings 111 | "DEFAULT_TASK_EXPIRY_MINUTES": int(os.environ.get("TASK_EXPIRY_MINUTES", 60)), 112 | "DEFAULT_ESTIMATED_TASK_SECONDS": int( 113 | os.environ.get("ESTIMATED_TASK_SECONDS", 60) 114 | ), 115 | "CLEANUP_INTERVAL_SECONDS": int( 116 | os.environ.get("CLEANUP_INTERVAL_SECONDS", 3600) 117 | ), # 1 hour 118 | "MAX_AGENT_STEPS": int(os.environ.get("MAX_AGENT_STEPS", 10)), 119 | # Browser arguments 120 | "BROWSER_ARGS": [ 121 | "--no-sandbox", 122 | "--disable-gpu", 123 | "--disable-software-rasterizer", 124 | "--disable-dev-shm-usage", 125 | "--remote-debugging-port=0", # Use random port to avoid conflicts 126 | ], 127 | # Patient mode - if true, functions wait for task completion before returning 128 | "PATIENT_MODE": parse_bool_env("PATIENT", False), 129 | } 130 | 131 | return config 132 | 133 | 134 | # Initialize configuration 135 | CONFIG = init_configuration() 136 | 137 | # Task storage for async operations 138 | task_store: Dict[str, Dict[str, Any]] = {} 139 | 140 | 141 | async def create_browser_context_for_task( 142 | chrome_path: Optional[str] = None, 143 | window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], 144 | window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], 145 | locale: str = CONFIG["DEFAULT_LOCALE"], 146 | ) -> Tuple[Browser, BrowserContext]: 147 | """ 148 | Create a fresh browser and context for a task. 149 | 150 | This function creates an isolated browser instance and context 151 | with proper configuration for a single task. 152 | 153 | Args: 154 | chrome_path: Path to Chrome executable 155 | window_width: Browser window width 156 | window_height: Browser window height 157 | locale: Browser locale 158 | 159 | Returns: 160 | A tuple containing the browser instance and browser context 161 | 162 | Raises: 163 | Exception: If browser or context creation fails 164 | """ 165 | try: 166 | # Create browser configuration 167 | browser_config = BrowserConfig( 168 | extra_chromium_args=CONFIG["BROWSER_ARGS"], 169 | ) 170 | 171 | # Set chrome path if provided 172 | if chrome_path: 173 | browser_config.chrome_instance_path = chrome_path 174 | 175 | # Create browser instance 176 | browser = Browser(config=browser_config) 177 | 178 | # Create context configuration 179 | context_config = BrowserContextConfig( 180 | wait_for_network_idle_page_load_time=0.6, 181 | maximum_wait_page_load_time=1.2, 182 | minimum_wait_page_load_time=0.2, 183 | browser_window_size={"width": window_width, "height": window_height}, 184 | locale=locale, 185 | user_agent=CONFIG["DEFAULT_USER_AGENT"], 186 | highlight_elements=True, 187 | viewport_expansion=0, 188 | ) 189 | 190 | # Create context with the browser 191 | context = BrowserContext(browser=browser, config=context_config) 192 | 193 | return browser, context 194 | except Exception as e: 195 | logger.error(f"Error creating browser context: {str(e)}") 196 | raise 197 | 198 | 199 | async def run_browser_task_async( 200 | task_id: str, 201 | url: str, 202 | action: str, 203 | llm: BaseLanguageModel, 204 | window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], 205 | window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], 206 | locale: str = CONFIG["DEFAULT_LOCALE"], 207 | ) -> None: 208 | """ 209 | Run a browser task asynchronously and store the result. 210 | 211 | This function executes a browser automation task with the given URL and action, 212 | and updates the task store with progress and results. 213 | 214 | When PATIENT_MODE is enabled, the calling function will wait for this function 215 | to complete before returning to the client. 216 | 217 | Args: 218 | task_id: Unique identifier for the task 219 | url: URL to navigate to 220 | action: Action to perform after navigation 221 | llm: Language model to use for browser agent 222 | window_width: Browser window width 223 | window_height: Browser window height 224 | locale: Browser locale 225 | """ 226 | browser = None 227 | context = None 228 | 229 | try: 230 | # Update task status to running 231 | task_store[task_id]["status"] = "running" 232 | task_store[task_id]["start_time"] = datetime.now().isoformat() 233 | task_store[task_id]["progress"] = { 234 | "current_step": 0, 235 | "total_steps": 0, 236 | "steps": [], 237 | } 238 | 239 | # Define step callback function with the correct signature 240 | async def step_callback( 241 | browser_state: Any, agent_output: Any, step_number: int 242 | ) -> None: 243 | # Update progress in task store 244 | task_store[task_id]["progress"]["current_step"] = step_number 245 | task_store[task_id]["progress"]["total_steps"] = max( 246 | task_store[task_id]["progress"]["total_steps"], step_number 247 | ) 248 | 249 | # Add step info with minimal details 250 | step_info = {"step": step_number, "time": datetime.now().isoformat()} 251 | 252 | # Add goal if available 253 | if agent_output and hasattr(agent_output, "current_state"): 254 | if hasattr(agent_output.current_state, "next_goal"): 255 | step_info["goal"] = agent_output.current_state.next_goal 256 | 257 | # Add to progress steps 258 | task_store[task_id]["progress"]["steps"].append(step_info) 259 | 260 | # Log progress 261 | logger.info(f"Task {task_id}: Step {step_number} completed") 262 | 263 | # Define done callback function with the correct signature 264 | async def done_callback(history: Any) -> None: 265 | # Log completion 266 | logger.info(f"Task {task_id}: Completed with {len(history.history)} steps") 267 | 268 | # Add final step 269 | current_step = task_store[task_id]["progress"]["current_step"] + 1 270 | task_store[task_id]["progress"]["steps"].append( 271 | { 272 | "step": current_step, 273 | "time": datetime.now().isoformat(), 274 | "status": "completed", 275 | } 276 | ) 277 | 278 | # Get Chrome path from environment if available 279 | chrome_path = os.environ.get("CHROME_PATH") 280 | 281 | # Create a fresh browser and context for this task 282 | browser, context = await create_browser_context_for_task( 283 | chrome_path=chrome_path, 284 | window_width=window_width, 285 | window_height=window_height, 286 | locale=locale, 287 | ) 288 | 289 | # Create agent with the fresh context 290 | agent = Agent( 291 | task=f"First, navigate to {url}. Then, {action}", 292 | llm=llm, 293 | browser_context=context, 294 | register_new_step_callback=step_callback, 295 | register_done_callback=done_callback, 296 | ) 297 | 298 | # Run the agent with a reasonable step limit 299 | agent_result = await agent.run(max_steps=CONFIG["MAX_AGENT_STEPS"]) 300 | 301 | # Get the final result 302 | final_result = agent_result.final_result() 303 | 304 | # Check if we have a valid result 305 | if final_result and hasattr(final_result, "raise_for_status"): 306 | final_result.raise_for_status() 307 | result_text = str(final_result.text) 308 | else: 309 | result_text = ( 310 | str(final_result) if final_result else "No final result available" 311 | ) 312 | 313 | # Gather essential information from the agent history 314 | is_successful = agent_result.is_successful() 315 | has_errors = agent_result.has_errors() 316 | errors = agent_result.errors() 317 | urls_visited = agent_result.urls() 318 | action_names = agent_result.action_names() 319 | extracted_content = agent_result.extracted_content() 320 | steps_taken = agent_result.number_of_steps() 321 | 322 | # Create a focused response with the most relevant information 323 | response_data = { 324 | "final_result": result_text, 325 | "success": is_successful, 326 | "has_errors": has_errors, 327 | "errors": [str(err) for err in errors if err], 328 | "urls_visited": [str(url) for url in urls_visited if url], 329 | "actions_performed": action_names, 330 | "extracted_content": extracted_content, 331 | "steps_taken": steps_taken, 332 | } 333 | 334 | # Store the result 335 | task_store[task_id]["status"] = "completed" 336 | task_store[task_id]["end_time"] = datetime.now().isoformat() 337 | task_store[task_id]["result"] = response_data 338 | 339 | except Exception as e: 340 | logger.error(f"Error in async browser task: {str(e)}") 341 | tb = traceback.format_exc() 342 | 343 | # Store the error 344 | task_store[task_id]["status"] = "failed" 345 | task_store[task_id]["end_time"] = datetime.now().isoformat() 346 | task_store[task_id]["error"] = str(e) 347 | task_store[task_id]["traceback"] = tb 348 | 349 | finally: 350 | # Clean up browser resources 351 | try: 352 | if context: 353 | await context.close() 354 | if browser: 355 | await browser.close() 356 | logger.info(f"Browser resources for task {task_id} cleaned up") 357 | except Exception as e: 358 | logger.error( 359 | f"Error cleaning up browser resources for task {task_id}: {str(e)}" 360 | ) 361 | 362 | 363 | async def cleanup_old_tasks() -> None: 364 | """ 365 | Periodically clean up old completed tasks to prevent memory leaks. 366 | 367 | This function runs continuously in the background, removing tasks that have been 368 | completed or failed for more than 1 hour to conserve memory. 369 | """ 370 | while True: 371 | try: 372 | # Sleep first to avoid cleaning up tasks too early 373 | await asyncio.sleep(CONFIG["CLEANUP_INTERVAL_SECONDS"]) 374 | 375 | current_time = datetime.now() 376 | tasks_to_remove = [] 377 | 378 | # Find completed tasks older than 1 hour 379 | for task_id, task_data in task_store.items(): 380 | if ( 381 | task_data["status"] in ["completed", "failed"] 382 | and "end_time" in task_data 383 | ): 384 | end_time = datetime.fromisoformat(task_data["end_time"]) 385 | hours_elapsed = (current_time - end_time).total_seconds() / 3600 386 | 387 | if hours_elapsed > 1: # Remove tasks older than 1 hour 388 | tasks_to_remove.append(task_id) 389 | 390 | # Remove old tasks 391 | for task_id in tasks_to_remove: 392 | del task_store[task_id] 393 | 394 | if tasks_to_remove: 395 | logger.info(f"Cleaned up {len(tasks_to_remove)} old tasks") 396 | 397 | except Exception as e: 398 | logger.error(f"Error in task cleanup: {str(e)}") 399 | 400 | 401 | def create_mcp_server( 402 | llm: BaseLanguageModel, 403 | task_expiry_minutes: int = CONFIG["DEFAULT_TASK_EXPIRY_MINUTES"], 404 | window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], 405 | window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], 406 | locale: str = CONFIG["DEFAULT_LOCALE"], 407 | ) -> Server: 408 | """ 409 | Create and configure an MCP server for browser interaction. 410 | 411 | Args: 412 | llm: The language model to use for browser agent 413 | task_expiry_minutes: Minutes after which tasks are considered expired 414 | window_width: Browser window width 415 | window_height: Browser window height 416 | locale: Browser locale 417 | 418 | Returns: 419 | Configured MCP server instance 420 | """ 421 | # Create MCP server instance 422 | app = Server("browser_use") 423 | 424 | @app.call_tool() 425 | async def call_tool( 426 | name: str, arguments: dict 427 | ) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: 428 | """ 429 | Handle tool calls from the MCP client. 430 | 431 | Args: 432 | name: The name of the tool to call 433 | arguments: The arguments to pass to the tool 434 | 435 | Returns: 436 | A list of content objects to return to the client. 437 | When PATIENT_MODE is enabled, the browser_use tool will wait for the task to complete 438 | and return the full result immediately instead of just the task ID. 439 | 440 | Raises: 441 | ValueError: If required arguments are missing 442 | """ 443 | # Handle browser_use tool 444 | if name == "browser_use": 445 | # Check required arguments 446 | if "url" not in arguments: 447 | raise ValueError("Missing required argument 'url'") 448 | if "action" not in arguments: 449 | raise ValueError("Missing required argument 'action'") 450 | 451 | # Generate a task ID 452 | task_id = str(uuid.uuid4()) 453 | 454 | # Initialize task in store 455 | task_store[task_id] = { 456 | "id": task_id, 457 | "status": "pending", 458 | "url": arguments["url"], 459 | "action": arguments["action"], 460 | "created_at": datetime.now().isoformat(), 461 | } 462 | 463 | # Start task in background 464 | _task = asyncio.create_task( 465 | run_browser_task_async( 466 | task_id=task_id, 467 | url=arguments["url"], 468 | action=arguments["action"], 469 | llm=llm, 470 | window_width=window_width, 471 | window_height=window_height, 472 | locale=locale, 473 | ) 474 | ) 475 | 476 | # If PATIENT is set, wait for the task to complete 477 | if CONFIG["PATIENT_MODE"]: 478 | try: 479 | await _task 480 | # Return the completed task result instead of just the ID 481 | task_data = task_store[task_id] 482 | if task_data["status"] == "failed": 483 | logger.error( 484 | f"Task {task_id} failed: {task_data.get('error', 'Unknown error')}" 485 | ) 486 | return [ 487 | types.TextContent( 488 | type="text", 489 | text=json.dumps(task_data, indent=2), 490 | ) 491 | ] 492 | except Exception as e: 493 | logger.error(f"Error in patient mode execution: {str(e)}") 494 | traceback_str = traceback.format_exc() 495 | # Update task store with error 496 | task_store[task_id]["status"] = "failed" 497 | task_store[task_id]["error"] = str(e) 498 | task_store[task_id]["traceback"] = traceback_str 499 | task_store[task_id]["end_time"] = datetime.now().isoformat() 500 | # Return error information 501 | return [ 502 | types.TextContent( 503 | type="text", 504 | text=json.dumps(task_store[task_id], indent=2), 505 | ) 506 | ] 507 | 508 | # Return task ID immediately with explicit sleep instruction 509 | return [ 510 | types.TextContent( 511 | type="text", 512 | text=json.dumps( 513 | { 514 | "task_id": task_id, 515 | "status": "pending", 516 | "message": f"Browser task started. Please wait for {CONFIG['DEFAULT_ESTIMATED_TASK_SECONDS']} seconds, then check the result using browser_get_result or the resource URI. Always wait exactly 5 seconds between status checks.", 517 | "estimated_time": f"{CONFIG['DEFAULT_ESTIMATED_TASK_SECONDS']} seconds", 518 | "resource_uri": f"resource://browser_task/{task_id}", 519 | "sleep_command": "sleep 5", 520 | "instruction": "Use the terminal command 'sleep 5' to wait 5 seconds between status checks. IMPORTANT: Always use exactly 5 seconds, no more and no less.", 521 | }, 522 | indent=2, 523 | ), 524 | ) 525 | ] 526 | 527 | # Handle browser_get_result tool 528 | elif name == "browser_get_result": 529 | # Get result of async task 530 | if "task_id" not in arguments: 531 | raise ValueError("Missing required argument 'task_id'") 532 | 533 | task_id = arguments["task_id"] 534 | 535 | if task_id not in task_store: 536 | return [ 537 | types.TextContent( 538 | type="text", 539 | text=json.dumps( 540 | {"error": "Task not found", "task_id": task_id}, indent=2 541 | ), 542 | ) 543 | ] 544 | 545 | # Get the current task data 546 | task_data = task_store[task_id].copy() 547 | 548 | # If task is still running, add simple guidance 549 | if task_data["status"] == "running": 550 | # Add a simple next check suggestion 551 | progress = task_data.get("progress", {}) 552 | current_step = progress.get("current_step", 0) 553 | 554 | if current_step > 0: 555 | # Simple message based on current step 556 | task_data["message"] = ( 557 | f"Task is running (step {current_step}). Wait 5 seconds before checking again." 558 | ) 559 | task_data["sleep_command"] = "sleep 5" 560 | task_data["instruction"] = ( 561 | "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." 562 | ) 563 | else: 564 | task_data["message"] = ( 565 | "Task is starting. Wait 5 seconds before checking again." 566 | ) 567 | task_data["sleep_command"] = "sleep 5" 568 | task_data["instruction"] = ( 569 | "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." 570 | ) 571 | 572 | # Return current task status and result if available 573 | return [ 574 | types.TextContent(type="text", text=json.dumps(task_data, indent=2)) 575 | ] 576 | 577 | else: 578 | raise ValueError(f"Unknown tool: {name}") 579 | 580 | @app.list_tools() 581 | async def list_tools() -> list[types.Tool]: 582 | """ 583 | List the available tools for the MCP client. 584 | 585 | Returns different tool descriptions based on the PATIENT_MODE configuration. 586 | When PATIENT_MODE is enabled, the browser_use tool description indicates it returns 587 | complete results directly. When disabled, it indicates async operation. 588 | 589 | Returns: 590 | A list of tool definitions appropriate for the current configuration 591 | """ 592 | patient_mode = CONFIG["PATIENT_MODE"] 593 | 594 | if patient_mode: 595 | return [ 596 | types.Tool( 597 | name="browser_use", 598 | description="Performs a browser action and returns the complete result directly (patient mode active)", 599 | inputSchema={ 600 | "type": "object", 601 | "required": ["url", "action"], 602 | "properties": { 603 | "url": { 604 | "type": "string", 605 | "description": "URL to navigate to", 606 | }, 607 | "action": { 608 | "type": "string", 609 | "description": "Action to perform in the browser", 610 | }, 611 | }, 612 | }, 613 | ), 614 | types.Tool( 615 | name="browser_get_result", 616 | description="Gets the result of an asynchronous browser task (not needed in patient mode as browser_use returns complete results directly)", 617 | inputSchema={ 618 | "type": "object", 619 | "required": ["task_id"], 620 | "properties": { 621 | "task_id": { 622 | "type": "string", 623 | "description": "ID of the task to get results for", 624 | } 625 | }, 626 | }, 627 | ), 628 | ] 629 | else: 630 | return [ 631 | types.Tool( 632 | name="browser_use", 633 | description="Performs a browser action and returns a task ID for async execution", 634 | inputSchema={ 635 | "type": "object", 636 | "required": ["url", "action"], 637 | "properties": { 638 | "url": { 639 | "type": "string", 640 | "description": "URL to navigate to", 641 | }, 642 | "action": { 643 | "type": "string", 644 | "description": "Action to perform in the browser", 645 | }, 646 | }, 647 | }, 648 | ), 649 | types.Tool( 650 | name="browser_get_result", 651 | description="Gets the result of an asynchronous browser task", 652 | inputSchema={ 653 | "type": "object", 654 | "required": ["task_id"], 655 | "properties": { 656 | "task_id": { 657 | "type": "string", 658 | "description": "ID of the task to get results for", 659 | } 660 | }, 661 | }, 662 | ), 663 | ] 664 | 665 | @app.list_resources() 666 | async def list_resources() -> list[types.Resource]: 667 | """ 668 | List the available resources for the MCP client. 669 | 670 | Returns: 671 | A list of resource definitions 672 | """ 673 | # List all completed tasks as resources 674 | resources = [] 675 | for task_id, task_data in task_store.items(): 676 | if task_data["status"] in ["completed", "failed"]: 677 | resources.append( 678 | types.Resource( 679 | uri=f"resource://browser_task/{task_id}", 680 | title=f"Browser Task Result: {task_id[:8]}", 681 | description=f"Result of browser task for URL: {task_data.get('url', 'unknown')}", 682 | ) 683 | ) 684 | return resources 685 | 686 | @app.read_resource() 687 | async def read_resource(uri: str) -> list[types.ResourceContents]: 688 | """ 689 | Read a resource for the MCP client. 690 | 691 | Args: 692 | uri: The URI of the resource to read 693 | 694 | Returns: 695 | The contents of the resource 696 | """ 697 | # Extract task ID from URI 698 | if not uri.startswith("resource://browser_task/"): 699 | return [ 700 | types.ResourceContents( 701 | type="text", 702 | text=json.dumps( 703 | {"error": f"Invalid resource URI: {uri}"}, indent=2 704 | ), 705 | ) 706 | ] 707 | 708 | task_id = uri.replace("resource://browser_task/", "") 709 | if task_id not in task_store: 710 | return [ 711 | types.ResourceContents( 712 | type="text", 713 | text=json.dumps({"error": f"Task not found: {task_id}"}, indent=2), 714 | ) 715 | ] 716 | 717 | # Return task data 718 | return [ 719 | types.ResourceContents( 720 | type="text", text=json.dumps(task_store[task_id], indent=2) 721 | ) 722 | ] 723 | 724 | # Add cleanup_old_tasks function to app for later scheduling 725 | app.cleanup_old_tasks = cleanup_old_tasks 726 | 727 | return app 728 | 729 | 730 | @click.command() 731 | @click.option("--port", default=8000, help="Port to listen on for SSE") 732 | @click.option( 733 | "--proxy-port", 734 | default=None, 735 | type=int, 736 | help="Port for the proxy to listen on. If specified, enables proxy mode.", 737 | ) 738 | @click.option("--chrome-path", default=None, help="Path to Chrome executable") 739 | @click.option( 740 | "--window-width", 741 | default=CONFIG["DEFAULT_WINDOW_WIDTH"], 742 | help="Browser window width", 743 | ) 744 | @click.option( 745 | "--window-height", 746 | default=CONFIG["DEFAULT_WINDOW_HEIGHT"], 747 | help="Browser window height", 748 | ) 749 | @click.option("--locale", default=CONFIG["DEFAULT_LOCALE"], help="Browser locale") 750 | @click.option( 751 | "--task-expiry-minutes", 752 | default=CONFIG["DEFAULT_TASK_EXPIRY_MINUTES"], 753 | help="Minutes after which tasks are considered expired", 754 | ) 755 | @click.option( 756 | "--stdio", 757 | is_flag=True, 758 | default=False, 759 | help="Enable stdio mode. If specified, enables proxy mode.", 760 | ) 761 | def main( 762 | port: int, 763 | proxy_port: Optional[int], 764 | chrome_path: str, 765 | window_width: int, 766 | window_height: int, 767 | locale: str, 768 | task_expiry_minutes: int, 769 | stdio: bool, 770 | ) -> int: 771 | """ 772 | Run the browser-use MCP server. 773 | 774 | This function initializes the MCP server and runs it with the SSE transport. 775 | Each browser task will create its own isolated browser context. 776 | 777 | The server can run in two modes: 778 | 1. Direct SSE mode (default): Just runs the SSE server 779 | 2. Proxy mode (enabled by --stdio or --proxy-port): Runs both SSE server and mcp-proxy 780 | 781 | Args: 782 | port: Port to listen on for SSE 783 | proxy_port: Port for the proxy to listen on. If specified, enables proxy mode. 784 | chrome_path: Path to Chrome executable 785 | window_width: Browser window width 786 | window_height: Browser window height 787 | locale: Browser locale 788 | task_expiry_minutes: Minutes after which tasks are considered expired 789 | stdio: Enable stdio mode. If specified, enables proxy mode. 790 | 791 | Returns: 792 | Exit code (0 for success) 793 | """ 794 | # Store Chrome path in environment variable if provided 795 | if chrome_path: 796 | os.environ["CHROME_PATH"] = chrome_path 797 | logger.info(f"Using Chrome path: {chrome_path}") 798 | else: 799 | logger.info( 800 | "No Chrome path specified, letting Playwright use its default browser" 801 | ) 802 | 803 | # Initialize LLM 804 | llm = ChatOpenAI(model="gpt-4o", temperature=0.0) 805 | 806 | # Create MCP server 807 | app = create_mcp_server( 808 | llm=llm, 809 | task_expiry_minutes=task_expiry_minutes, 810 | window_width=window_width, 811 | window_height=window_height, 812 | locale=locale, 813 | ) 814 | 815 | sse = SseServerTransport("/messages/") 816 | 817 | # Create the Starlette app for SSE 818 | async def handle_sse(request): 819 | """Handle SSE connections from clients.""" 820 | try: 821 | async with sse.connect_sse( 822 | request.scope, request.receive, request._send 823 | ) as streams: 824 | await app.run( 825 | streams[0], streams[1], app.create_initialization_options() 826 | ) 827 | except Exception as e: 828 | logger.error(f"Error in handle_sse: {str(e)}") 829 | raise 830 | 831 | starlette_app = Starlette( 832 | debug=True, 833 | routes=[ 834 | Route("/sse", endpoint=handle_sse), 835 | Mount("/messages/", app=sse.handle_post_message), 836 | ], 837 | ) 838 | 839 | # Add startup event 840 | @starlette_app.on_event("startup") 841 | async def startup_event(): 842 | """Initialize the server on startup.""" 843 | logger.info("Starting MCP server...") 844 | 845 | # Sanity checks for critical configuration 846 | if port <= 0 or port > 65535: 847 | logger.error(f"Invalid port number: {port}") 848 | raise ValueError(f"Invalid port number: {port}") 849 | 850 | if window_width <= 0 or window_height <= 0: 851 | logger.error(f"Invalid window dimensions: {window_width}x{window_height}") 852 | raise ValueError( 853 | f"Invalid window dimensions: {window_width}x{window_height}" 854 | ) 855 | 856 | if task_expiry_minutes <= 0: 857 | logger.error(f"Invalid task expiry minutes: {task_expiry_minutes}") 858 | raise ValueError(f"Invalid task expiry minutes: {task_expiry_minutes}") 859 | 860 | # Start background task cleanup 861 | asyncio.create_task(app.cleanup_old_tasks()) 862 | logger.info("Task cleanup process scheduled") 863 | 864 | # Function to run uvicorn in a separate thread 865 | def run_uvicorn(): 866 | # Configure uvicorn to use JSON logging 867 | log_config = { 868 | "version": 1, 869 | "disable_existing_loggers": False, 870 | "formatters": { 871 | "json": { 872 | "()": "pythonjsonlogger.jsonlogger.JsonFormatter", 873 | "fmt": '{"time":"%(asctime)s","level":"%(levelname)s","name":"%(name)s","message":"%(message)s"}', 874 | } 875 | }, 876 | "handlers": { 877 | "default": { 878 | "formatter": "json", 879 | "class": "logging.StreamHandler", 880 | "stream": "ext://sys.stderr", 881 | } 882 | }, 883 | "loggers": { 884 | "": {"handlers": ["default"], "level": "INFO"}, 885 | "uvicorn": {"handlers": ["default"], "level": "INFO"}, 886 | "uvicorn.error": {"handlers": ["default"], "level": "INFO"}, 887 | "uvicorn.access": {"handlers": ["default"], "level": "INFO"}, 888 | }, 889 | } 890 | 891 | uvicorn.run( 892 | starlette_app, 893 | host="0.0.0.0", # nosec 894 | port=port, 895 | log_config=log_config, 896 | log_level="info", 897 | ) 898 | 899 | # If proxy mode is enabled, run both the SSE server and mcp-proxy 900 | if stdio: 901 | import subprocess # nosec 902 | 903 | # Start the SSE server in a separate thread 904 | sse_thread = threading.Thread(target=run_uvicorn) 905 | sse_thread.daemon = True 906 | sse_thread.start() 907 | 908 | # Give the SSE server a moment to start 909 | time.sleep(1) 910 | 911 | proxy_cmd = [ 912 | "mcp-proxy", 913 | f"http://localhost:{port}/sse", 914 | "--sse-port", 915 | str(proxy_port), 916 | "--allow-origin", 917 | "*", 918 | ] 919 | 920 | logger.info(f"Running proxy command: {' '.join(proxy_cmd)}") 921 | logger.info( 922 | f"SSE server running on port {port}, proxy running on port {proxy_port}" 923 | ) 924 | 925 | try: 926 | # Using trusted command arguments from CLI parameters 927 | with subprocess.Popen(proxy_cmd) as proxy_process: # nosec 928 | proxy_process.wait() 929 | except Exception as e: 930 | logger.error(f"Error starting mcp-proxy: {str(e)}") 931 | logger.error(f"Command was: {' '.join(proxy_cmd)}") 932 | return 1 933 | else: 934 | logger.info(f"Running in direct SSE mode on port {port}") 935 | run_uvicorn() 936 | 937 | return 0 938 | 939 | 940 | if __name__ == "__main__": 941 | main() 942 | -------------------------------------------------------------------------------- /src/browser_use_mcp_server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Browser-Use MCP Server Package 3 | 4 | This package provides a Model-Control-Protocol (MCP) server for browser automation 5 | using the browser_use library. 6 | """ 7 | -------------------------------------------------------------------------------- /src/browser_use_mcp_server/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line interface for browser-use-mcp-server. 3 | 4 | This module provides a command-line interface for starting the browser-use MCP server. 5 | It wraps the existing server functionality with a CLI. 6 | """ 7 | 8 | import json 9 | import logging 10 | import sys 11 | from typing import Optional 12 | 13 | import click 14 | from pythonjsonlogger import jsonlogger 15 | 16 | # Import directly from our package 17 | from browser_use_mcp_server.server import main as server_main 18 | 19 | # Configure logging for CLI 20 | logger = logging.getLogger() 21 | logger.handlers = [] # Remove any existing handlers 22 | handler = logging.StreamHandler(sys.stderr) 23 | formatter = jsonlogger.JsonFormatter( 24 | '{"time":"%(asctime)s","level":"%(levelname)s","name":"%(name)s","message":"%(message)s"}' 25 | ) 26 | handler.setFormatter(formatter) 27 | logger.addHandler(handler) 28 | logger.setLevel(logging.INFO) 29 | 30 | 31 | def log_error(message: str, error: Optional[Exception] = None): 32 | """Log error in JSON format to stderr""" 33 | error_data = {"error": message, "traceback": str(error) if error else None} 34 | print(json.dumps(error_data), file=sys.stderr) 35 | 36 | 37 | @click.group() 38 | def cli(): 39 | """Browser-use MCP server command line interface.""" 40 | 41 | 42 | @cli.command() 43 | @click.argument("subcommand") 44 | @click.option("--port", default=8000, help="Port to listen on for SSE") 45 | @click.option( 46 | "--proxy-port", 47 | default=None, 48 | type=int, 49 | help="Port for the proxy to listen on (when using stdio mode)", 50 | ) 51 | @click.option("--chrome-path", default=None, help="Path to Chrome executable") 52 | @click.option("--window-width", default=1280, help="Browser window width") 53 | @click.option("--window-height", default=1100, help="Browser window height") 54 | @click.option("--locale", default="en-US", help="Browser locale") 55 | @click.option( 56 | "--task-expiry-minutes", 57 | default=60, 58 | help="Minutes after which tasks are considered expired", 59 | ) 60 | @click.option( 61 | "--stdio", is_flag=True, default=False, help="Enable stdio mode with mcp-proxy" 62 | ) 63 | def run( 64 | subcommand, 65 | port, 66 | proxy_port, 67 | chrome_path, 68 | window_width, 69 | window_height, 70 | locale, 71 | task_expiry_minutes, 72 | stdio, 73 | ): 74 | """Run the browser-use MCP server. 75 | 76 | SUBCOMMAND: should be 'server' 77 | """ 78 | if subcommand != "server": 79 | log_error(f"Unknown subcommand: {subcommand}. Only 'server' is supported.") 80 | sys.exit(1) 81 | 82 | try: 83 | # We need to construct the command line arguments to pass to the server's Click command 84 | old_argv = sys.argv.copy() 85 | 86 | # Build a new argument list for the server command 87 | new_argv = [ 88 | "server", # Program name 89 | "--port", 90 | str(port), 91 | ] 92 | 93 | if chrome_path: 94 | new_argv.extend(["--chrome-path", chrome_path]) 95 | 96 | if proxy_port is not None: 97 | new_argv.extend(["--proxy-port", str(proxy_port)]) 98 | 99 | new_argv.extend(["--window-width", str(window_width)]) 100 | new_argv.extend(["--window-height", str(window_height)]) 101 | new_argv.extend(["--locale", locale]) 102 | new_argv.extend(["--task-expiry-minutes", str(task_expiry_minutes)]) 103 | 104 | if stdio: 105 | new_argv.append("--stdio") 106 | 107 | # Replace sys.argv temporarily 108 | sys.argv = new_argv 109 | 110 | # Run the server's command directly 111 | try: 112 | return server_main() 113 | finally: 114 | # Restore original sys.argv 115 | sys.argv = old_argv 116 | 117 | except Exception as e: 118 | log_error("Error starting server", e) 119 | sys.exit(1) 120 | 121 | 122 | if __name__ == "__main__": 123 | cli() 124 | -------------------------------------------------------------------------------- /src/browser_use_mcp_server/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Server module that re-exports the main server module. 3 | 4 | This provides a clean import path for the CLI and other code. 5 | """ 6 | 7 | from server.server import ( 8 | CONFIG, 9 | Server, 10 | cleanup_old_tasks, 11 | create_browser_context_for_task, 12 | create_mcp_server, 13 | init_configuration, 14 | main, 15 | run_browser_task_async, 16 | task_store, 17 | ) 18 | 19 | # Re-export everything we imported 20 | __all__ = [ 21 | "Server", 22 | "main", 23 | "create_browser_context_for_task", 24 | "run_browser_task_async", 25 | "cleanup_old_tasks", 26 | "create_mcp_server", 27 | "init_configuration", 28 | "CONFIG", 29 | "task_store", 30 | ] 31 | --------------------------------------------------------------------------------