├── .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 | [](https://x.com/cobrowser)
6 | [](https://discord.gg/gw9UpFUhyY)
7 | [](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 |
196 |
197 |
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 |
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 |
--------------------------------------------------------------------------------