diff --git a/.bandit.yml b/.bandit.yml new file mode 100644 index 0000000..9085791 --- /dev/null +++ b/.bandit.yml @@ -0,0 +1,9 @@ +--- +# Bandit configuration file + +# Skip specific test IDs +skips: [B104, B404, B603] + +# Plugin configs +any_other_function_with_shell_equals_true: + no_shell: [subprocess.Popen] \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8991ac9 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 88 +extend-ignore = E501 +per-file-ignores = + server/server.py:E501 + src/browser_use_mcp_server/cli.py:E501 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00fc465..104995a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,6 @@ jobs: - name: "Python lint" run: uvx ruff check . - - name: "Validate project metadata" - run: uvx --from 'validate-pyproject[all,store]' validate-pyproject pyproject.toml - build-and-publish: runs-on: ubuntu-latest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index d77bb7b..52943f9 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 1 + fetch-depth: 0 - name: Set up uv and Python uses: astral-sh/setup-uv@v5 @@ -30,18 +30,14 @@ jobs: python-version: "3.13" cache-dependency-glob: "pyproject.toml" - - name: Install python packages - run: | - uv sync - - name: Build a binary wheel and a source tarball - run: | - uv build --sdist --wheel --out-dir dist + - name: Build + run: uv build - - name: Publish build artifacts + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: pypi-dists - path: "./dist" + path: dist/ pypi-publish: runs-on: ubuntu-latest @@ -73,6 +69,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ + github-release: name: >- Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..bafe386 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,59 @@ +[MASTER] +# Python version +py-version = 3.11 + +# Disable specific messages +disable= + C0301, # Line too long + R0402, # Use 'from mcp import types' instead + W1203, # Use lazy % formatting in logging functions + R0913, # Too many arguments + R0917, # Too many positional arguments + R0914, # Too many local variables + W0718, # Catching too general exception Exception + R0915, # Too many statements + W0613, # Unused argument + R1705, # Unnecessary "elif" after "return" + R0912, # Too many branches + W0621, # Redefining name from outer scope + W0404, # Reimport + C0415, # Import outside toplevel + W0212, # Access to a protected member + W0107, # Unnecessary pass statement + R0801, # Similar lines in files + import-error, + no-value-for-parameter, + logging-fstring-interpolation, + protected-access, + redefined-outer-name, + reimported + +# Add files or directories to the blacklist +ignore=.git,__pycache__,.venv,dist,build + +# Use multiple processes to speed up Pylint +jobs=4 + +[FORMAT] +# Maximum number of characters on a single line +max-line-length=120 + +# Maximum number of lines in a module +max-module-lines=300 + +[MESSAGES CONTROL] +# Only show warnings with the listed confidence levels +confidence=HIGH,CONTROL_FLOW + +[DESIGN] +# Maximum number of arguments for function / method +max-args=10 + +# Maximum number of locals for function / method +max-locals=30 + +# Maximum number of statements in function / method body +max-statements=60 + +# Maximum number of branch for function / method body +max-branches=15 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9adab69..a96f139 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ENV UV_COMPILE_BYTECODE=1 \ # Install build dependencies and clean up in the same layer RUN apt-get update -y && \ - apt-get install --no-install-recommends -y clang && \ + apt-get install --no-install-recommends -y clang git && \ rm -rf /var/lib/apt/lists/* # Install Python before the project for caching @@ -57,7 +57,8 @@ COPY --from=builder /app /app # Set proper permissions RUN chmod -R 755 /python /app -ENV PATH="/app/.venv/bin:$PATH" \ +ENV ANONYMIZED_TELEMETRY=false \ + PATH="/app/.venv/bin:$PATH" \ DISPLAY=:0 \ CHROME_BIN=/usr/bin/chromium \ CHROMIUM_FLAGS="--no-sandbox --headless --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage" diff --git a/pyproject.toml b/pyproject.toml index 38c1cfb..bf9aa1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,10 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "browser-use-mcp-server" -version = "0.1.3" +dynamic = ["version"] description = "MCP browser-use server library" readme = "README.md" requires-python = ">=3.11,<4.0" +license = {text = "MIT"} authors = [ {name = "Cobrowser Team"} ] @@ -32,17 +29,19 @@ dependencies = [ ] [project.optional-dependencies] -dev = [ +# Dependencies for running tests +test = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", +] +# Dependencies for development (includes test dependencies) +dev = [ + "browser-use-mcp-server[test]", "black>=23.0.0", "isort>=5.12.0", "mypy>=1.0.0", -] -test = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", - "pytest-cov>=4.1.0", + "ruff>=0.5.5", ] [project.urls] @@ -53,6 +52,7 @@ test = [ testpaths = ["tests"] python_files = "test_*.py" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.black] line-length = 88 @@ -72,9 +72,39 @@ disallow_incomplete_defs = true [project.scripts] browser-use-mcp-server = "browser_use_mcp_server.cli:cli" +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + [tool.hatch.build] -packages = ["src", "server"] -include = ["server"] +include = ["src/browser_use_mcp_server", "server"] [tool.hatch.build.targets.wheel] packages = ["src/browser_use_mcp_server", "server"] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +style = "pep440" +bump = true + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +# Enable common Pyflakes, pycodestyle, and isort rules +select = ["E", "F", "W", "I"] +# Ignore line length violations in comments, docstrings, and string literals +extend-ignore = ["E501"] + +# Exclude string literals and comments from line length checks +[tool.ruff.lint.per-file-ignores] +"server/server.py" = ["E501"] +"src/browser_use_mcp_server/cli.py" = ["E501"] + +[tool.ruff.format] +# Use black-compatible formatting +quote-style = "double" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..1d23be8 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "reportMissingImports": false, + "reportMissingModuleSource": false, + "reportOptionalMemberAccess": false, + "reportAttributeAccessIssue": false, + "reportCallIssue": false, + "reportFunctionMemberAccess": false +} \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py index cfa8a22..d6cba9a 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,3 +1,29 @@ -from .server import main +""" +Browser-Use MCP Server core implementation. -__all__ = ["main"] +This package provides the core implementation of the MCP server for browser automation. +""" + +from .server import ( + CONFIG, + Server, + cleanup_old_tasks, + create_browser_context_for_task, + create_mcp_server, + init_configuration, + main, + run_browser_task_async, + task_store, +) + +__all__ = [ + "Server", + "main", + "create_browser_context_for_task", + "run_browser_task_async", + "cleanup_old_tasks", + "create_mcp_server", + "init_configuration", + "CONFIG", + "task_store", +] diff --git a/server/server.py b/server/server.py index 52eff2c..5ef695c 100644 --- a/server/server.py +++ b/server/server.py @@ -10,34 +10,41 @@ """ # Standard library imports -import os import asyncio import json import logging +import os +import sys + +# Set up SSE transport +import threading +import time import traceback import uuid from datetime import datetime from typing import Any, Dict, Optional, Tuple, Union -import time -import sys # Third-party imports import click -from dotenv import load_dotenv -from pythonjsonlogger import jsonlogger +import mcp.types as types +import uvicorn # Browser-use library imports from browser_use import Agent from browser_use.browser.browser import Browser, BrowserConfig from browser_use.browser.context import BrowserContext, BrowserContextConfig - -# MCP server components -from mcp.server import Server -import mcp.types as types +from dotenv import load_dotenv +from langchain_core.language_models import BaseLanguageModel # LLM provider from langchain_openai import ChatOpenAI -from langchain_core.language_models import BaseLanguageModel + +# MCP server components +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from pythonjsonlogger import jsonlogger +from starlette.applications import Starlette +from starlette.routing import Mount, Route # Configure logging logger = logging.getLogger() @@ -805,14 +812,6 @@ def main( locale=locale, ) - # Set up SSE transport - from mcp.server.sse import SseServerTransport - from starlette.applications import Starlette - from starlette.routing import Mount, Route - import uvicorn - import asyncio - import threading - sse = SseServerTransport("/messages/") # Create the Starlette app for SSE @@ -891,7 +890,7 @@ def run_uvicorn(): uvicorn.run( starlette_app, - host="0.0.0.0", + host="0.0.0.0", # nosec port=port, log_config=log_config, log_level="info", @@ -899,7 +898,7 @@ def run_uvicorn(): # If proxy mode is enabled, run both the SSE server and mcp-proxy if stdio: - import subprocess + import subprocess # nosec # Start the SSE server in a separate thread sse_thread = threading.Thread(target=run_uvicorn) @@ -924,7 +923,8 @@ def run_uvicorn(): ) try: - with subprocess.Popen(proxy_cmd) as proxy_process: + # Using trusted command arguments from CLI parameters + with subprocess.Popen(proxy_cmd) as proxy_process: # nosec proxy_process.wait() except Exception as e: logger.error(f"Error starting mcp-proxy: {str(e)}") diff --git a/src/browser_use_mcp_server/__init__.py b/src/browser_use_mcp_server/__init__.py index 853878c..6b4973f 100644 --- a/src/browser_use_mcp_server/__init__.py +++ b/src/browser_use_mcp_server/__init__.py @@ -4,5 +4,3 @@ This package provides a Model-Control-Protocol (MCP) server for browser automation using the browser_use library. """ - -__version__ = "0.1.3" diff --git a/src/browser_use_mcp_server/cli.py b/src/browser_use_mcp_server/cli.py index da15747..94f8f2e 100644 --- a/src/browser_use_mcp_server/cli.py +++ b/src/browser_use_mcp_server/cli.py @@ -5,14 +5,17 @@ It wraps the existing server functionality with a CLI. """ -import os -import sys import json import logging +import sys +from typing import Optional + import click -import importlib.util from pythonjsonlogger import jsonlogger +# Import directly from our package +from browser_use_mcp_server.server import main as server_main + # Configure logging for CLI logger = logging.getLogger() logger.handlers = [] # Remove any existing handlers @@ -25,50 +28,15 @@ logger.setLevel(logging.INFO) -def log_error(message: str, error: Exception = None): +def log_error(message: str, error: Optional[Exception] = None): """Log error in JSON format to stderr""" error_data = {"error": message, "traceback": str(error) if error else None} print(json.dumps(error_data), file=sys.stderr) -def import_server_module(): - """ - Import the server module from the server directory. - This allows us to reuse the existing server code. - """ - # Add the root directory to the Python path to find server module - root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) - sys.path.insert(0, root_dir) - - try: - # Try to import the server module - import server.server - - return server.server - except ImportError: - # If running as an installed package, the server module might be elsewhere - try: - # Look in common locations - if os.path.exists(os.path.join(root_dir, "server", "server.py")): - spec = importlib.util.spec_from_file_location( - "server.server", os.path.join(root_dir, "server", "server.py") - ) - server_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(server_module) - return server_module - except Exception as e: - log_error("Could not import server module", e) - raise ImportError(f"Could not import server module: {e}") - - raise ImportError( - "Could not find server module. Make sure it's installed correctly." - ) - - @click.group() def cli(): """Browser-use MCP server command line interface.""" - pass @cli.command() @@ -112,9 +80,6 @@ def run( sys.exit(1) try: - # Import the server module - server_module = import_server_module() - # We need to construct the command line arguments to pass to the server's Click command old_argv = sys.argv.copy() @@ -144,7 +109,7 @@ def run( # Run the server's command directly try: - return server_module.main() + return server_main() finally: # Restore original sys.argv sys.argv = old_argv diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py index a101b80..4679aad 100644 --- a/src/browser_use_mcp_server/server.py +++ b/src/browser_use_mcp_server/server.py @@ -4,24 +4,18 @@ This provides a clean import path for the CLI and other code. """ -import os -import sys from server.server import ( + CONFIG, Server, - main, - create_browser_context_for_task, - run_browser_task_async, cleanup_old_tasks, + create_browser_context_for_task, create_mcp_server, init_configuration, - CONFIG, + main, + run_browser_task_async, task_store, ) -# Add the root directory to the Python path to find server module -root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -sys.path.insert(0, root_dir) - # Re-export everything we imported __all__ = [ "Server",