From 7f5d84c77f162ee5fea0b36303ee0cd94707a874 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Fri, 14 Mar 2025 11:16:16 +0100 Subject: [PATCH 1/4] context fix --- src/browser_use_mcp_server/server.py | 523 +++++++++++++++++---------- 1 file changed, 337 insertions(+), 186 deletions(-) diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py index ea07d80..9e55d3b 100644 --- a/src/browser_use_mcp_server/server.py +++ b/src/browser_use_mcp_server/server.py @@ -123,36 +123,68 @@ async def reset_browser_context(context: BrowserContext) -> None: try: logger.info("Resetting browser context...") - # Since Browser doesn't have pages() or new_page() methods, - # we need to use the methods that are available + # Check if browser is closed + try: + # Try a simple operation to check if browser is still alive + if hasattr(context.browser, "new_context"): + await context.browser.new_context() + browser_context_healthy = True + logger.info("Browser context reset successfully") + return + except Exception as e: + logger.warning(f"Browser context appears to be closed: {e}") + # Continue with reinitializing the browser - # Try to refresh the page if possible + # If we get here, we need to reinitialize the browser try: - # If the context has a current page, try to reload it - if hasattr(context, "current_page") and context.current_page: - await context.current_page.reload() - logger.info("Current page reloaded") - - # Or navigate to a blank page to reset state - if hasattr(context, "navigate"): - await context.navigate("about:blank") - logger.info("Navigated to blank page") - - # If we have access to create a new context, use that - if hasattr(context, "create_new_context"): - await context.create_new_context() - logger.info("Created new context") - - # As a last resort, try to initialize a new context - if hasattr(context.browser, "initialize"): - await context.browser.initialize() - logger.info("Re-initialized browser") + # Get the original configuration from the context if possible + config = None + if hasattr(context, "config"): + config = context.config + + # Reinitialize the browser with the same configuration + browser_config = BrowserConfig( + chrome_path=os.environ.get("CHROME_PATH"), + extra_chromium_args=[ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=9222", + ], + ) + + # Create a new browser instance + browser = Browser(config=browser_config) + await browser.initialize() + + # Create a new context with the same configuration as before + context_config = BrowserContextConfig( + wait_for_network_idle_page_load_time=0.6, + maximum_wait_page_load_time=1.2, + minimum_wait_page_load_time=0.2, + browser_window_size={"width": 1280, "height": 1100}, + locale="en-US", + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", + highlight_elements=True, + viewport_expansion=0, + ) + + # Replace the old browser with the new one + context.browser = browser + context.config = context_config + + # Create a new browser context + await context.browser.new_context(context_config) + + logger.info("Browser reinitialized successfully") + browser_context_healthy = True + return except Exception as e: - logger.warning(f"Error performing specific reset operations: {e}") + logger.error(f"Failed to reinitialize browser: {e}") + browser_context_healthy = False + raise - # Mark as healthy - browser_context_healthy = True - logger.info("Browser context reset successfully") except Exception as e: browser_context_healthy = False logger.error(f"Failed to reset browser context: {e}") @@ -172,25 +204,38 @@ async def check_browser_health(context: BrowserContext) -> bool: """ global browser_context_healthy - # Debug: Log available methods and attributes - try: - context_methods = [ - method for method in dir(context) if not method.startswith("_") - ] - logger.info(f"BrowserContext available methods: {context_methods}") + # First, check if the browser context is already marked as unhealthy + if not browser_context_healthy: + logger.info("Browser context marked as unhealthy, attempting reset...") + try: + await reset_browser_context(context) + logger.info("Browser context successfully reset") + return True + except Exception as e: + logger.error(f"Failed to recover browser context: {e}") + return False - if hasattr(context, "browser"): - browser_methods = [ - method for method in dir(context.browser) if not method.startswith("_") - ] - logger.info(f"Browser available methods: {browser_methods}") + # Try a simple operation to check if browser is still alive + try: + # Check if browser is still responsive + if hasattr(context.browser, "new_context"): + # Just check if the method exists, don't actually call it + browser_context_healthy = True + logger.debug("Browser context appears healthy") + else: + # If the method doesn't exist, mark as unhealthy + logger.warning("Browser context missing expected methods") + browser_context_healthy = False except Exception as e: - logger.warning(f"Error logging available methods: {e}") + logger.warning(f"Error checking browser health: {e}") + browser_context_healthy = False + # If marked as unhealthy, try to reset if not browser_context_healthy: - logger.info("Browser context marked as unhealthy, attempting reset...") + logger.info("Browser context appears unhealthy, attempting reset...") try: await reset_browser_context(context) + logger.info("Browser context successfully reset") return True except Exception as e: logger.error(f"Failed to recover browser context: {e}") @@ -211,6 +256,7 @@ async def run_browser_task_async( ] = None, done_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, task_expiry_minutes: int = 60, + max_retries: int = 1, ) -> str: """ Run a browser task asynchronously. @@ -225,6 +271,7 @@ async def run_browser_task_async( step_callback: Optional callback for each step of the task done_callback: Optional callback for when the task is complete task_expiry_minutes: Minutes after which the task is considered expired + max_retries: Maximum number of retries if the task fails due to browser issues Returns: Task ID @@ -269,155 +316,113 @@ async def default_done_callback(history): step_cb = step_callback if step_callback is not None else default_step_callback done_cb = done_callback if done_callback is not None else default_done_callback - try: - # Check and ensure browser health - browser_healthy = await check_browser_health(context) - if not browser_healthy: - raise Exception("Browser context is unhealthy") - - # Create agent and run task + retries = 0 + while retries <= max_retries: try: - # Inspect Agent class initialization parameters - agent_params = inspect.signature(Agent.__init__).parameters - logger.info(f"Agent init parameters: {list(agent_params.keys())}") - - # Adapt initialization based on available parameters - agent_kwargs = {"context": context} - - if "llm" in agent_params: - agent_kwargs["llm"] = llm - - # Add task parameter which is required based on the error message - if "task" in agent_params: - # Create a task that combines navigation and the action - task_description = f"First, navigate to {url}. Then, {action}" - agent_kwargs["task"] = task_description - - # Add browser and browser_context parameters if they're required - if "browser" in agent_params: - agent_kwargs["browser"] = context.browser - if "browser_context" in agent_params: - agent_kwargs["browser_context"] = context - - # Check for callbacks - if "step_callback" in agent_params: - agent_kwargs["step_callback"] = step_cb - if "done_callback" in agent_params: - agent_kwargs["done_callback"] = done_cb - - # Register callbacks with the new parameter names if the old ones don't exist - if ( - "step_callback" not in agent_params - and "register_new_step_callback" in agent_params - ): - agent_kwargs["register_new_step_callback"] = step_cb - if ( - "done_callback" not in agent_params - and "register_done_callback" in agent_params - ): - agent_kwargs["register_done_callback"] = done_cb - - # Check if all required parameters are set - missing_params = [] - for param_name, param in agent_params.items(): + # Check and ensure browser health + browser_healthy = await check_browser_health(context) + if not browser_healthy: + raise Exception("Browser context is unhealthy") + + # Create agent and run task + try: + # Inspect Agent class initialization parameters + agent_params = inspect.signature(Agent.__init__).parameters + logger.info(f"Agent init parameters: {list(agent_params.keys())}") + + # Adapt initialization based on available parameters + agent_kwargs = {"context": context} + + if "llm" in agent_params: + agent_kwargs["llm"] = llm + + # Add task parameter which is required based on the error message + if "task" in agent_params: + # Create a task that combines navigation and the action + task_description = f"First, navigate to {url}. Then, {action}" + agent_kwargs["task"] = task_description + + # Add browser and browser_context parameters if they're required + if "browser" in agent_params: + agent_kwargs["browser"] = context.browser + if "browser_context" in agent_params: + agent_kwargs["browser_context"] = context + + # Check for callbacks + if "step_callback" in agent_params: + agent_kwargs["step_callback"] = step_cb + if "done_callback" in agent_params: + agent_kwargs["done_callback"] = done_cb + + # Register callbacks with the new parameter names if the old ones don't exist if ( - param.default == inspect.Parameter.empty - and param_name != "self" - and param_name not in agent_kwargs + "step_callback" not in agent_params + and "register_new_step_callback" in agent_params ): - missing_params.append(param_name) - - if missing_params: - logger.error(f"Missing required parameters for Agent: {missing_params}") - raise Exception( - f"Missing required parameters for Agent: {missing_params}" - ) + agent_kwargs["register_new_step_callback"] = step_cb + if ( + "done_callback" not in agent_params + and "register_done_callback" in agent_params + ): + agent_kwargs["register_done_callback"] = done_cb + + # Check if all required parameters are set + missing_params = [] + for param_name, param in agent_params.items(): + if ( + param.default == inspect.Parameter.empty + and param_name != "self" + and param_name not in agent_kwargs + ): + missing_params.append(param_name) + + if missing_params: + logger.error( + f"Missing required parameters for Agent: {missing_params}" + ) + raise Exception( + f"Missing required parameters for Agent: {missing_params}" + ) - # Create agent with appropriate parameters - agent = Agent(**agent_kwargs) + # Create agent with appropriate parameters + agent = Agent(**agent_kwargs) - # Launch task asynchronously - # Don't pass any parameters to run() as they should already be set via init - asyncio.create_task(agent.run()) - return task_id - except Exception as agent_error: - logger.error(f"Error creating Agent: {str(agent_error)}") - raise Exception(f"Failed to create browser agent: {str(agent_error)}") + # Launch task asynchronously + # Don't pass any parameters to run() as they should already be set via init + asyncio.create_task(agent.run()) + return task_id + except Exception as agent_error: + logger.error(f"Error creating Agent: {str(agent_error)}") + raise Exception(f"Failed to create browser agent: {str(agent_error)}") - except Exception as e: - # Update task store with error - store[task_id]["status"] = "error" - store[task_id]["error"] = str(e) - store[task_id]["end_time"] = datetime.now().isoformat() - logger.error(f"Task {task_id}: Error - {str(e)}") + except Exception as e: + # Update task store with error + store[task_id]["error"] = str(e) + logger.error(f"Task {task_id}: Error - {str(e)}") - # Attempt one more browser reset as a last resort - if "Browser context is unhealthy" in str(e): - try: - logger.info( - f"Task {task_id}: Final attempt to reset browser context..." - ) + # If we've reached max retries, mark as error and exit + if retries >= max_retries: + store[task_id]["status"] = "error" + store[task_id]["end_time"] = datetime.now().isoformat() + logger.error(f"Task {task_id}: Failed after {retries + 1} attempts") + raise - # Use a simpler recovery approach - try: - # Try to use any available method to reset the context - if hasattr(context, "current_page") and context.current_page: - await context.current_page.reload() - logger.info(f"Task {task_id}: Current page reloaded") - - if hasattr(context, "navigate"): - await context.navigate("about:blank") - logger.info(f"Task {task_id}: Navigated to blank page") - - # Mark as healthy and retry - global browser_context_healthy - browser_context_healthy = True - logger.info( - f"Task {task_id}: Browser context recovered, retrying..." - ) + # Otherwise, try to reset the browser context and retry + retries += 1 + logger.info(f"Task {task_id}: Retry attempt {retries}/{max_retries}") - # Retry the task - try: - # Use the same dynamic approach for agent initialization - agent_kwargs = {"context": context} - - if "llm" in inspect.signature(Agent.__init__).parameters: - agent_kwargs["llm"] = llm - - # Check for callbacks - if ( - "step_callback" - in inspect.signature(Agent.__init__).parameters - ): - agent_kwargs["step_callback"] = step_cb - if ( - "done_callback" - in inspect.signature(Agent.__init__).parameters - ): - agent_kwargs["done_callback"] = done_cb - - # Create agent with appropriate parameters - agent = Agent(**agent_kwargs) - - # Launch task asynchronously - asyncio.create_task(agent.run()) - store[task_id]["status"] = "running" - store[task_id]["error"] = None - return task_id - except Exception as agent_error: - logger.error( - f"Task {task_id}: Error creating Agent during retry: {str(agent_error)}" - ) - raise - except Exception as retry_error: - logger.error(f"Task {task_id}: Retry failed - {str(retry_error)}") + try: + # Reset browser context before retrying + await reset_browser_context(context) + logger.info(f"Task {task_id}: Browser context reset for retry") except Exception as reset_error: logger.error( - f"Task {task_id}: Final reset attempt failed - {str(reset_error)}" + f"Task {task_id}: Failed to reset browser context: {str(reset_error)}" ) + # Continue with retry even if reset fails - # Re-raise the exception - raise + # This should never be reached due to the raise in the loop + return task_id def create_mcp_server( @@ -506,6 +511,130 @@ async def call_tool( ) ] + elif name == "mcp__browser_use": + # Validate required arguments + if "url" not in arguments: + logger.error("URL argument missing in browser_use call") + return [types.TextContent(type="text", text="Error: URL is required")] + + if "action" not in arguments: + logger.error("Action argument missing in browser_use call") + return [ + types.TextContent(type="text", text="Error: Action is required") + ] + + url = arguments["url"] + action = arguments["action"] + + logger.info(f"Browser use request to URL: {url} with action: {action}") + + # Generate unique task ID + task_id = str(uuid.uuid4()) + + try: + # Run browser task + await run_browser_task_async( + context=context, + llm=llm, + task_id=task_id, + url=url, + action=action, + custom_task_store=store, + task_expiry_minutes=task_expiry_minutes, + ) + + logger.info(f"Browser task {task_id} started successfully") + + # Return task ID for async execution + return [ + types.TextContent( + type="text", + text=f"Task {task_id} started. Use mcp__browser_get_result with this task ID to get results when complete.", + ) + ] + + except Exception as e: + logger.error(f"Error executing browser task: {str(e)}") + return [ + types.TextContent( + type="text", text=f"Error executing browser task: {str(e)}" + ) + ] + + elif name == "mcp__browser_get_result": + # Validate required arguments + if "task_id" not in arguments: + logger.error("Task ID argument missing in browser_get_result call") + return [ + types.TextContent(type="text", text="Error: Task ID is required") + ] + + task_id = arguments["task_id"] + logger.info(f"Result request for task: {task_id}") + + # Check if task exists + if task_id not in store: + return [ + types.TextContent( + type="text", text=f"Error: Task {task_id} not found" + ) + ] + + task = store[task_id] + + # Check task status + if task["status"] == "error": + return [types.TextContent(type="text", text=f"Error: {task['error']}")] + + if task["status"] == "running": + # For running tasks, return the steps completed so far + steps_text = "\n".join( + [ + f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" + for s in task["steps"] + ] + ) + return [ + types.TextContent( + type="text", + text=f"Task {task_id} is still running.\n\nSteps completed so far:\n{steps_text}", + ) + ] + + # For completed tasks, return the full result + if task["result"]: + # Format the result as text + result_text = "Task completed successfully.\n\n" + result_text += f"URL: {task['url']}\n\n" + result_text += f"Action: {task['action']}\n\n" + + # Add final result if available + if isinstance(task["result"], dict) and "text" in task["result"]: + result_text += f"Result: {task['result']['text']}\n\n" + elif isinstance(task["result"], str): + result_text += f"Result: {task['result']}\n\n" + else: + # Try to extract result from the last step + if task["steps"] and task["steps"][-1].get("agent_output"): + last_output = task["steps"][-1]["agent_output"] + if "done" in last_output and "text" in last_output["done"]: + result_text += f"Result: {last_output['done']['text']}\n\n" + + return [ + types.TextContent( + type="text", + text=result_text, + ) + ] + + # Fallback for unexpected cases + return [ + types.TextContent( + type="text", + text=f"Task {task_id} completed with status '{task['status']}' but no results are available.", + ) + ] + elif name == "mcp__browser_health": try: # Check browser health @@ -562,7 +691,7 @@ async def list_tools() -> list[types.Tool]: tools = [ types.Tool( name="mcp__browser_navigate", - description="Navigate to a URL and perform an action", + description="Navigate to a URL and perform an action. This is a synchronous operation that will return when the task is complete.", inputSchema={ "type": "object", "properties": { @@ -579,31 +708,53 @@ async def list_tools() -> list[types.Tool]: }, ), types.Tool( - name="mcp__browser_health", - description="Check browser health status", + name="mcp__browser_use", + description="Performs a browser action asynchronously and returns a task ID. Use mcp__browser_get_result with the returned task ID to check the status and get results.", inputSchema={ "type": "object", "properties": { - "random_string": { + "url": { "type": "string", - "description": "Dummy parameter for no-parameter tools", + "description": "URL to navigate to", + }, + "action": { + "type": "string", + "description": "Action to perform in the browser (e.g., 'Extract all headlines', 'Search for X')", }, }, - "required": ["random_string"], + "required": ["url", "action"], }, ), types.Tool( - name="mcp__browser_reset", - description="Reset browser context", + name="mcp__browser_get_result", + description="Gets the result of an asynchronous browser task. Use this to check the status of a task started with mcp__browser_use.", inputSchema={ "type": "object", "properties": { - "random_string": { + "task_id": { "type": "string", - "description": "Dummy parameter for no-parameter tools", + "description": "ID of the task to get results for", }, }, - "required": ["random_string"], + "required": ["task_id"], + }, + ), + types.Tool( + name="mcp__browser_health", + description="Check browser health status and attempt recovery if needed", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + types.Tool( + name="mcp__browser_reset", + description="Force reset of the browser context to recover from errors", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], }, ), ] From 2b83805d6ef81d59b52824b56dcf08cdf4f52f67 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Fri, 14 Mar 2025 21:37:23 +0100 Subject: [PATCH 2/4] feat: Improve browser task isolation and resource management - Create isolated browser contexts for each task to prevent conflicts - Replace global browser context with task-specific instances - Add proper resource cleanup in finally blocks to prevent memory leaks - Extract hardcoded values as constants for better maintainability - Add comprehensive type hints throughout the codebase - Improve error handling and logging - Reorganize imports and improve code structure - Update Dockerfile to use Playwright's bundled browser instead of custom Chrome - Ensure each task runs in its own isolated environment with unique debugging ports - Fix potential race conditions when multiple tasks access the same context --- Dockerfile | 9 +- server/server.py | 419 ++++++++++++++++++++++++----------------------- uv.lock | 130 +++++++-------- 3 files changed, 287 insertions(+), 271 deletions(-) diff --git a/Dockerfile b/Dockerfile index cd4ec92..7def187 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN apt-get update -y && \ # Install Python before the project for caching RUN uv python install 3.13 + WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ @@ -35,8 +36,6 @@ RUN apt-get update && \ tigervnc-tools \ nodejs \ npm \ - chromium \ - chromium-driver \ fonts-freefont-ttf \ fonts-ipafont-gothic \ fonts-wqy-zenhei \ @@ -56,8 +55,7 @@ COPY --from=builder --chown=app:app /app /app ENV 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" \ - CHROME_PATH="/usr/bin/chromium" + CHROMIUM_FLAGS="--no-sandbox --headless --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage" # Combine VNC setup commands to reduce layers RUN mkdir -p ~/.vnc && \ @@ -69,6 +67,9 @@ RUN mkdir -p ~/.vnc && \ chmod +x /app/boot.sh +RUN playwright install --with-deps --no-shell chromium + + EXPOSE 8000 ENTRYPOINT ["/bin/bash", "/app/boot.sh"] diff --git a/server/server.py b/server/server.py index cf76721..b742aea 100644 --- a/server/server.py +++ b/server/server.py @@ -9,26 +9,30 @@ The server supports Server-Sent Events (SSE) for web-based interfaces. """ +# Standard library imports import os -import click import asyncio +import json +import logging +import traceback import uuid from datetime import datetime +from typing import Any, Dict, Optional, Tuple, Union + +# Third-party imports +import click from dotenv import load_dotenv -import logging -import json -import traceback -# Import from browser-use library -from browser_use.browser.context import BrowserContextConfig, BrowserContext -from browser_use.browser.browser import Browser, BrowserConfig +# 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 -# Import MCP server components +# MCP server components from mcp.server.lowlevel import Server import mcp.types as types -# Import LLM provider +# LLM provider from langchain_openai import ChatOpenAI # Configure logging @@ -38,99 +42,96 @@ # Load environment variables load_dotenv() -# Task storage for async operations -task_store = {} - -# Flag to track browser context health -browser_context_healthy = True - -# Store global browser context and configuration -browser = None -context = None -config = None +# Constants +DEFAULT_WINDOW_WIDTH = 1280 +DEFAULT_WINDOW_HEIGHT = 1100 +DEFAULT_LOCALE = "en-US" +DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" +DEFAULT_TASK_EXPIRY_MINUTES = 60 +DEFAULT_ESTIMATED_TASK_SECONDS = 60 +CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour +MAX_AGENT_STEPS = 10 + +# Browser configuration arguments +BROWSER_ARGS = [ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=0", # Use random port to avoid conflicts +] +# Task storage for async operations +task_store: Dict[str, Dict[str, Any]] = {} -async def reset_browser_context(): - """ - Reset the browser context to a clean state. - This function attempts to close the existing context and create a new one. - If that fails, it tries to recreate the entire browser instance. +async def create_browser_context_for_task( + chrome_path: Optional[str] = None, + window_width: int = DEFAULT_WINDOW_WIDTH, + window_height: int = DEFAULT_WINDOW_HEIGHT, + locale: str = DEFAULT_LOCALE, +) -> Tuple[Browser, BrowserContext]: """ - global context, browser, browser_context_healthy, config - - logger.info("Resetting browser context") - try: - # Try to close the existing context - try: - await context.close() - except Exception as e: - logger.warning(f"Error closing browser context: {str(e)}") - - # Create a new context - context = BrowserContext(browser=browser, config=config) - browser_context_healthy = True - logger.info("Browser context reset successfully") - except Exception as e: - logger.error(f"Failed to reset browser context: {str(e)}") - browser_context_healthy = False + Create a fresh browser and context for a task. - # If we can't reset the context, try to reset the browser - try: - await browser.close() - - # Recreate browser with same configuration - browser_config = BrowserConfig( - extra_chromium_args=[ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=9222", - ], - ) - - # Only set chrome_instance_path if we have a path from environment or command line - chrome_path = os.environ.get("CHROME_PATH") - if chrome_path: - browser_config.chrome_instance_path = chrome_path + This function creates an isolated browser instance and context + with proper configuration for a single task. - browser = Browser(config=browser_config) - context = BrowserContext(browser=browser, config=config) - browser_context_healthy = True - logger.info("Browser reset successfully") - except Exception as e: - logger.error(f"Failed to reset browser: {str(e)}") - browser_context_healthy = False + Args: + chrome_path: Path to Chrome executable + window_width: Browser window width + window_height: Browser window height + locale: Browser locale + Returns: + A tuple containing the browser instance and browser context -async def check_browser_health(): + Raises: + Exception: If browser or context creation fails """ - Check if the browser context is healthy by attempting to access the current page. - - If the context is unhealthy, attempts to reset it. + try: + # Create browser configuration + browser_config = BrowserConfig( + extra_chromium_args=BROWSER_ARGS, + ) - Returns: - bool: True if the browser context is healthy, False otherwise. - """ - global browser_context_healthy + # Set chrome path if provided + if chrome_path: + browser_config.chrome_instance_path = chrome_path - if not browser_context_healthy: - await reset_browser_context() - return browser_context_healthy + # Create browser instance + browser = Browser(config=browser_config) - try: - # Simple health check - try to get the current page - await context.get_current_page() - return True - except Exception as e: - logger.warning(f"Browser health check failed: {str(e)}") - browser_context_healthy = False - await reset_browser_context() - return browser_context_healthy + # Create context configuration + context_config = BrowserContextConfig( + wait_for_network_idle_page_load_time=0.6, + maximum_wait_page_load_time=1.2, + minimum_wait_page_load_time=0.2, + browser_window_size={"width": window_width, "height": window_height}, + locale=locale, + user_agent=DEFAULT_USER_AGENT, + highlight_elements=True, + viewport_expansion=0, + ) + # Create context with the browser + context = BrowserContext(browser=browser, config=context_config) -async def run_browser_task_async(task_id, url, action, llm): + return browser, context + except Exception as e: + logger.error(f"Error creating browser context: {str(e)}") + raise + + +async def run_browser_task_async( + task_id: str, + url: str, + action: str, + llm: Any, + window_width: int = DEFAULT_WINDOW_WIDTH, + window_height: int = DEFAULT_WINDOW_HEIGHT, + locale: str = DEFAULT_LOCALE, +) -> None: """ Run a browser task asynchronously and store the result. @@ -138,11 +139,17 @@ async def run_browser_task_async(task_id, url, action, llm): and updates the task store with progress and results. Args: - task_id (str): Unique identifier for the task - url (str): URL to navigate to - action (str): Action to perform after navigation + task_id: Unique identifier for the task + url: URL to navigate to + action: Action to perform after navigation llm: Language model to use for browser agent + window_width: Browser window width + window_height: Browser window height + locale: Browser locale """ + browser = None + context = None + try: # Update task status to running task_store[task_id]["status"] = "running" @@ -153,20 +160,10 @@ async def run_browser_task_async(task_id, url, action, llm): "steps": [], } - # Reset browser context to ensure a clean state - await reset_browser_context() - - # Check browser health - if not await check_browser_health(): - task_store[task_id]["status"] = "failed" - task_store[task_id]["end_time"] = datetime.now().isoformat() - task_store[task_id]["error"] = ( - "Browser context is unhealthy and could not be reset" - ) - return - # Define step callback function with the correct signature - async def step_callback(browser_state, agent_output, step_number): + async def step_callback( + browser_state: Any, agent_output: Any, step_number: int + ) -> None: # Update progress in task store task_store[task_id]["progress"]["current_step"] = step_number task_store[task_id]["progress"]["total_steps"] = max( @@ -188,7 +185,7 @@ async def step_callback(browser_state, agent_output, step_number): logger.info(f"Task {task_id}: Step {step_number} completed") # Define done callback function with the correct signature - async def done_callback(history): + async def done_callback(history: Any) -> None: # Log completion logger.info(f"Task {task_id}: Completed with {len(history.history)} steps") @@ -202,7 +199,18 @@ async def done_callback(history): } ) - # Use the existing browser context with callbacks + # Get Chrome path from environment if available + chrome_path = os.environ.get("CHROME_PATH") + + # Create a fresh browser and context for this task + browser, context = await create_browser_context_for_task( + chrome_path=chrome_path, + window_width=window_width, + window_height=window_height, + locale=locale, + ) + + # Create agent with the fresh context agent = Agent( task=f"First, navigate to {url}. Then, {action}", llm=llm, @@ -212,10 +220,10 @@ async def done_callback(history): ) # Run the agent with a reasonable step limit - ret = await agent.run(max_steps=10) + agent_result = await agent.run(max_steps=MAX_AGENT_STEPS) # Get the final result - final_result = ret.final_result() + final_result = agent_result.final_result() # Check if we have a valid result if final_result and hasattr(final_result, "raise_for_status"): @@ -227,13 +235,13 @@ async def done_callback(history): ) # Gather essential information from the agent history - is_successful = ret.is_successful() - has_errors = ret.has_errors() - errors = ret.errors() - urls_visited = ret.urls() - action_names = ret.action_names() - extracted_content = ret.extracted_content() - steps_taken = ret.number_of_steps() + is_successful = agent_result.is_successful() + has_errors = agent_result.has_errors() + errors = agent_result.errors() + urls_visited = agent_result.urls() + action_names = agent_result.action_names() + extracted_content = agent_result.extracted_content() + steps_taken = agent_result.number_of_steps() # Create a focused response with the most relevant information response_data = { @@ -256,10 +264,6 @@ async def done_callback(history): logger.error(f"Error in async browser task: {str(e)}") tb = traceback.format_exc() - # Mark the browser context as unhealthy - global browser_context_healthy - browser_context_healthy = False - # Store the error task_store[task_id]["status"] = "failed" task_store[task_id]["end_time"] = datetime.now().isoformat() @@ -267,16 +271,20 @@ async def done_callback(history): task_store[task_id]["traceback"] = tb finally: - # Always try to reset the browser context to a clean state after use + # Clean up browser resources try: - current_page = await context.get_current_page() - await current_page.goto("about:blank") + if context: + await context.close() + if browser: + await browser.close() + logger.info(f"Browser resources for task {task_id} cleaned up") except Exception as e: - logger.warning(f"Error resetting page state: {str(e)}") - browser_context_healthy = False + logger.error( + f"Error cleaning up browser resources for task {task_id}: {str(e)}" + ) -async def cleanup_old_tasks(): +async def cleanup_old_tasks() -> None: """ Periodically clean up old completed tasks to prevent memory leaks. @@ -286,7 +294,7 @@ async def cleanup_old_tasks(): while True: try: # Sleep first to avoid cleaning up tasks too early - await asyncio.sleep(3600) # Run cleanup every hour + await asyncio.sleep(CLEANUP_INTERVAL_SECONDS) current_time = datetime.now() tasks_to_remove = [] @@ -314,16 +322,25 @@ async def cleanup_old_tasks(): logger.error(f"Error in task cleanup: {str(e)}") -def create_mcp_server(llm, task_expiry_minutes=60): +def create_mcp_server( + llm: Any, + task_expiry_minutes: int = DEFAULT_TASK_EXPIRY_MINUTES, + window_width: int = DEFAULT_WINDOW_WIDTH, + window_height: int = DEFAULT_WINDOW_HEIGHT, + locale: str = DEFAULT_LOCALE, +) -> Server: """ Create and configure an MCP server for browser interaction. Args: llm: The language model to use for browser agent - task_expiry_minutes (int): Minutes after which tasks are considered expired + task_expiry_minutes: Minutes after which tasks are considered expired + window_width: Browser window width + window_height: Browser window height + locale: Browser locale Returns: - Server: Configured MCP server instance + Configured MCP server instance """ # Create MCP server instance app = Server("browser_use") @@ -331,9 +348,20 @@ def create_mcp_server(llm, task_expiry_minutes=60): @app.call_tool() async def call_tool( name: str, arguments: dict - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - global browser_context_healthy + ) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: + """ + Handle tool calls from the MCP client. + + Args: + name: The name of the tool to call + arguments: The arguments to pass to the tool + + Returns: + A list of content objects to return to the client + Raises: + ValueError: If required arguments are missing + """ # Handle browser_use tool if name == "browser_use": # Check required arguments @@ -357,13 +385,16 @@ async def call_tool( # Start task in background asyncio.create_task( run_browser_task_async( - task_id, arguments["url"], arguments["action"], llm + task_id=task_id, + url=arguments["url"], + action=arguments["action"], + llm=llm, + window_width=window_width, + window_height=window_height, + locale=locale, ) ) - # Estimate task duration - estimated_seconds = 60 # Default estimate of 60 seconds - # Return task ID immediately with explicit sleep instruction return [ types.TextContent( @@ -372,8 +403,8 @@ async def call_tool( { "task_id": task_id, "status": "pending", - "message": f"Browser task started. Please wait for {estimated_seconds} seconds, then check the result using browser_get_result or the resource URI. Always wait exactly 5 seconds between status checks.", - "estimated_time": f"{estimated_seconds} seconds", + "message": f"Browser task started. Please wait for {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.", + "estimated_time": f"{DEFAULT_ESTIMATED_TASK_SECONDS} seconds", "resource_uri": f"resource://browser_task/{task_id}", "sleep_command": "sleep 5", "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.", @@ -438,6 +469,12 @@ async def call_tool( @app.list_tools() async def list_tools() -> list[types.Tool]: + """ + List the available tools for the MCP client. + + Returns: + A list of tool definitions + """ return [ types.Tool( name="browser_use", @@ -475,6 +512,12 @@ async def list_tools() -> list[types.Tool]: @app.list_resources() async def list_resources() -> list[types.Resource]: + """ + List the available resources for the MCP client. + + Returns: + A list of resource definitions + """ # List all completed tasks as resources resources = [] for task_id, task_data in task_store.items(): @@ -490,6 +533,15 @@ async def list_resources() -> list[types.Resource]: @app.read_resource() async def read_resource(uri: str) -> list[types.ResourceContents]: + """ + Read a resource for the MCP client. + + Args: + uri: The URI of the resource to read + + Returns: + The contents of the resource + """ # Extract task ID from URI if not uri.startswith("resource://browser_task/"): return [ @@ -532,22 +584,22 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: ) @click.option( "--window-width", - default=1280, + default=DEFAULT_WINDOW_WIDTH, help="Browser window width", ) @click.option( "--window-height", - default=1100, + default=DEFAULT_WINDOW_HEIGHT, help="Browser window height", ) @click.option( "--locale", - default="en-US", + default=DEFAULT_LOCALE, help="Browser locale", ) @click.option( "--task-expiry-minutes", - default=60, + default=DEFAULT_TASK_EXPIRY_MINUTES, help="Minutes after which tasks are considered expired", ) def main( @@ -561,57 +613,29 @@ def main( """ Run the browser-use MCP server. - This function initializes the browser context, creates the MCP server, - and runs it with the SSE transport. - """ - global browser, context, config, browser_context_healthy + This function initializes the MCP server and runs it with the SSE transport. + Each browser task will create its own isolated browser context. + + Args: + port: Port to listen on for SSE + chrome_path: Path to Chrome executable + window_width: Browser window width + window_height: Browser window height + locale: Browser locale + task_expiry_minutes: Minutes after which tasks are considered expired - # Use Chrome path from command line arg, environment variable, or None - chrome_executable_path = chrome_path or os.environ.get("CHROME_PATH") - if chrome_executable_path: - logger.info(f"Using Chrome path: {chrome_executable_path}") + Returns: + Exit code (0 for success) + """ + # Store Chrome path in environment variable if provided + if chrome_path: + os.environ["CHROME_PATH"] = chrome_path + logger.info(f"Using Chrome path: {chrome_path}") else: logger.info( "No Chrome path specified, letting Playwright use its default browser" ) - # Initialize browser context - try: - # Browser context configuration - config = BrowserContextConfig( - wait_for_network_idle_page_load_time=0.6, - maximum_wait_page_load_time=1.2, - minimum_wait_page_load_time=0.2, - browser_window_size={"width": window_width, "height": window_height}, - locale=locale, - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", - highlight_elements=True, - viewport_expansion=0, - ) - - # Initialize browser and context directly - browser_config = BrowserConfig( - extra_chromium_args=[ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=9222", - ], - ) - - # Only set chrome_instance_path if we actually found a path - if chrome_executable_path: - browser_config.chrome_instance_path = chrome_executable_path - - browser = Browser(config=browser_config) - context = BrowserContext(browser=browser, config=config) - browser_context_healthy = True - logger.info("Browser context initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize browser context: {str(e)}") - return 1 - # Initialize LLM llm = ChatOpenAI(model="gpt-4o", temperature=0.0) @@ -619,6 +643,9 @@ def main( app = create_mcp_server( llm=llm, task_expiry_minutes=task_expiry_minutes, + window_width=window_width, + window_height=window_height, + locale=locale, ) # Set up SSE transport @@ -630,6 +657,7 @@ def main( sse = SseServerTransport("/messages/") async def handle_sse(request): + """Handle SSE connections from clients.""" try: async with sse.connect_sse( request.scope, request.receive, request._send @@ -639,8 +667,6 @@ async def handle_sse(request): ) except Exception as e: logger.error(f"Error in handle_sse: {str(e)}") - # Ensure browser context is reset if there's an error - asyncio.create_task(reset_browser_context()) raise starlette_app = Starlette( @@ -651,27 +677,16 @@ async def handle_sse(request): ], ) - # Add a startup event to initialize the browser + # Add a startup event @starlette_app.on_event("startup") async def startup_event(): - logger.info("Starting browser context...") - await reset_browser_context() - logger.info("Browser context started") + """Initialize the server on startup.""" + logger.info("Starting MCP server...") # Start background task cleanup asyncio.create_task(app.cleanup_old_tasks()) logger.info("Task cleanup process scheduled") - # Add a shutdown event to clean up browser resources - @starlette_app.on_event("shutdown") - async def shutdown_event(): - logger.info("Shutting down browser context...") - try: - await browser.close() - logger.info("Browser context closed successfully") - except Exception as e: - logger.error(f"Error closing browser: {str(e)}") - # Run uvicorn server uvicorn.run(starlette_app, host="0.0.0.0", port=port) diff --git a/uv.lock b/uv.lock index 95b1e1e..8d2b758 100644 --- a/uv.lock +++ b/uv.lock @@ -130,7 +130,7 @@ wheels = [ [[package]] name = "browser-use-mcp-server" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -497,49 +497,49 @@ wheels = [ [[package]] name = "jiter" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/70/90bc7bd3932e651486861df5c8ffea4ca7c77d28e8532ddefe2abc561a53/jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d", size = 163007 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b0/c1a7caa7f9dc5f1f6cfa08722867790fe2d3645d6e7170ca280e6e52d163/jiter-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2dd61c5afc88a4fda7d8b2cf03ae5947c6ac7516d32b7a15bf4b49569a5c076b", size = 303666 }, - { url = "https://files.pythonhosted.org/packages/f5/97/0468bc9eeae43079aaa5feb9267964e496bf13133d469cfdc135498f8dd0/jiter-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c710d657c8d1d2adbbb5c0b0c6bfcec28fd35bd6b5f016395f9ac43e878a15", size = 311934 }, - { url = "https://files.pythonhosted.org/packages/e5/69/64058e18263d9a5f1e10f90c436853616d5f047d997c37c7b2df11b085ec/jiter-0.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9584de0cd306072635fe4b89742bf26feae858a0683b399ad0c2509011b9dc0", size = 335506 }, - { url = "https://files.pythonhosted.org/packages/9d/14/b747f9a77b8c0542141d77ca1e2a7523e854754af2c339ac89a8b66527d6/jiter-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a90a923338531b7970abb063cfc087eebae6ef8ec8139762007188f6bc69a9f", size = 355849 }, - { url = "https://files.pythonhosted.org/packages/53/e2/98a08161db7cc9d0e39bc385415890928ff09709034982f48eccfca40733/jiter-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21974d246ed0181558087cd9f76e84e8321091ebfb3a93d4c341479a736f099", size = 381700 }, - { url = "https://files.pythonhosted.org/packages/7a/38/1674672954d35bce3b1c9af99d5849f9256ac8f5b672e020ac7821581206/jiter-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32475a42b2ea7b344069dc1e81445cfc00b9d0e3ca837f0523072432332e9f74", size = 389710 }, - { url = "https://files.pythonhosted.org/packages/f8/9b/92f9da9a9e107d019bcf883cd9125fa1690079f323f5a9d5c6986eeec3c0/jiter-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9931fd36ee513c26b5bf08c940b0ac875de175341cbdd4fa3be109f0492586", size = 345553 }, - { url = "https://files.pythonhosted.org/packages/44/a6/6d030003394e9659cd0d7136bbeabd82e869849ceccddc34d40abbbbb269/jiter-0.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0820f4a3a59ddced7fce696d86a096d5cc48d32a4183483a17671a61edfddc", size = 376388 }, - { url = "https://files.pythonhosted.org/packages/ad/8d/87b09e648e4aca5f9af89e3ab3cfb93db2d1e633b2f2931ede8dabd9b19a/jiter-0.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ffc86ae5e3e6a93765d49d1ab47b6075a9c978a2b3b80f0f32628f39caa0c88", size = 511226 }, - { url = "https://files.pythonhosted.org/packages/77/95/8008ebe4cdc82eac1c97864a8042ca7e383ed67e0ec17bfd03797045c727/jiter-0.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5127dc1abd809431172bc3fbe8168d6b90556a30bb10acd5ded41c3cfd6f43b6", size = 504134 }, - { url = "https://files.pythonhosted.org/packages/26/0d/3056a74de13e8b2562e4d526de6dac2f65d91ace63a8234deb9284a1d24d/jiter-0.8.2-cp311-cp311-win32.whl", hash = "sha256:66227a2c7b575720c1871c8800d3a0122bb8ee94edb43a5685aa9aceb2782d44", size = 203103 }, - { url = "https://files.pythonhosted.org/packages/4e/1e/7f96b798f356e531ffc0f53dd2f37185fac60fae4d6c612bbbd4639b90aa/jiter-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:cde031d8413842a1e7501e9129b8e676e62a657f8ec8166e18a70d94d4682855", size = 206717 }, - { url = "https://files.pythonhosted.org/packages/a1/17/c8747af8ea4e045f57d6cfd6fc180752cab9bc3de0e8a0c9ca4e8af333b1/jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f", size = 302027 }, - { url = "https://files.pythonhosted.org/packages/3c/c1/6da849640cd35a41e91085723b76acc818d4b7d92b0b6e5111736ce1dd10/jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44", size = 310326 }, - { url = "https://files.pythonhosted.org/packages/06/99/a2bf660d8ccffee9ad7ed46b4f860d2108a148d0ea36043fd16f4dc37e94/jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f", size = 334242 }, - { url = "https://files.pythonhosted.org/packages/a7/5f/cea1c17864828731f11427b9d1ab7f24764dbd9aaf4648a7f851164d2718/jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60", size = 356654 }, - { url = "https://files.pythonhosted.org/packages/e9/13/62774b7e5e7f5d5043efe1d0f94ead66e6d0f894ae010adb56b3f788de71/jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57", size = 379967 }, - { url = "https://files.pythonhosted.org/packages/ec/fb/096b34c553bb0bd3f2289d5013dcad6074948b8d55212aa13a10d44c5326/jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e", size = 389252 }, - { url = "https://files.pythonhosted.org/packages/17/61/beea645c0bf398ced8b199e377b61eb999d8e46e053bb285c91c3d3eaab0/jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887", size = 345490 }, - { url = "https://files.pythonhosted.org/packages/d5/df/834aa17ad5dcc3cf0118821da0a0cf1589ea7db9832589278553640366bc/jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d", size = 376991 }, - { url = "https://files.pythonhosted.org/packages/67/80/87d140399d382fb4ea5b3d56e7ecaa4efdca17cd7411ff904c1517855314/jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152", size = 510822 }, - { url = "https://files.pythonhosted.org/packages/5c/37/3394bb47bac1ad2cb0465601f86828a0518d07828a650722e55268cdb7e6/jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29", size = 503730 }, - { url = "https://files.pythonhosted.org/packages/f9/e2/253fc1fa59103bb4e3aa0665d6ceb1818df1cd7bf3eb492c4dad229b1cd4/jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e", size = 203375 }, - { url = "https://files.pythonhosted.org/packages/41/69/6d4bbe66b3b3b4507e47aa1dd5d075919ad242b4b1115b3f80eecd443687/jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c", size = 204740 }, - { url = "https://files.pythonhosted.org/packages/6c/b0/bfa1f6f2c956b948802ef5a021281978bf53b7a6ca54bb126fd88a5d014e/jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84", size = 301190 }, - { url = "https://files.pythonhosted.org/packages/a4/8f/396ddb4e292b5ea57e45ade5dc48229556b9044bad29a3b4b2dddeaedd52/jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4", size = 309334 }, - { url = "https://files.pythonhosted.org/packages/7f/68/805978f2f446fa6362ba0cc2e4489b945695940656edd844e110a61c98f8/jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587", size = 333918 }, - { url = "https://files.pythonhosted.org/packages/b3/99/0f71f7be667c33403fa9706e5b50583ae5106d96fab997fa7e2f38ee8347/jiter-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b2998606d6dadbb5ccda959a33d6a5e853252d921fec1792fc902351bb4e2c", size = 356057 }, - { url = "https://files.pythonhosted.org/packages/8d/50/a82796e421a22b699ee4d2ce527e5bcb29471a2351cbdc931819d941a167/jiter-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab9a87f3784eb0e098f84a32670cfe4a79cb6512fd8f42ae3d0709f06405d18", size = 379790 }, - { url = "https://files.pythonhosted.org/packages/3c/31/10fb012b00f6d83342ca9e2c9618869ab449f1aa78c8f1b2193a6b49647c/jiter-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79aec8172b9e3c6d05fd4b219d5de1ac616bd8da934107325a6c0d0e866a21b6", size = 388285 }, - { url = "https://files.pythonhosted.org/packages/c8/81/f15ebf7de57be488aa22944bf4274962aca8092e4f7817f92ffa50d3ee46/jiter-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711e408732d4e9a0208008e5892c2966b485c783cd2d9a681f3eb147cf36c7ef", size = 344764 }, - { url = "https://files.pythonhosted.org/packages/b3/e8/0cae550d72b48829ba653eb348cdc25f3f06f8a62363723702ec18e7be9c/jiter-0.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:653cf462db4e8c41995e33d865965e79641ef45369d8a11f54cd30888b7e6ff1", size = 376620 }, - { url = "https://files.pythonhosted.org/packages/b8/50/e5478ff9d82534a944c03b63bc217c5f37019d4a34d288db0f079b13c10b/jiter-0.8.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9c63eaef32b7bebac8ebebf4dabebdbc6769a09c127294db6babee38e9f405b9", size = 510402 }, - { url = "https://files.pythonhosted.org/packages/8e/1e/3de48bbebbc8f7025bd454cedc8c62378c0e32dd483dece5f4a814a5cb55/jiter-0.8.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:eb21aaa9a200d0a80dacc7a81038d2e476ffe473ffdd9c91eb745d623561de05", size = 503018 }, - { url = "https://files.pythonhosted.org/packages/d5/cd/d5a5501d72a11fe3e5fd65c78c884e5164eefe80077680533919be22d3a3/jiter-0.8.2-cp313-cp313-win32.whl", hash = "sha256:789361ed945d8d42850f919342a8665d2dc79e7e44ca1c97cc786966a21f627a", size = 203190 }, - { url = "https://files.pythonhosted.org/packages/51/bf/e5ca301245ba951447e3ad677a02a64a8845b185de2603dabd83e1e4b9c6/jiter-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ab7f43235d71e03b941c1630f4b6e3055d46b6cb8728a17663eaac9d8e83a865", size = 203551 }, - { url = "https://files.pythonhosted.org/packages/2f/3c/71a491952c37b87d127790dd7a0b1ebea0514c6b6ad30085b16bbe00aee6/jiter-0.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b426f72cd77da3fec300ed3bc990895e2dd6b49e3bfe6c438592a3ba660e41ca", size = 308347 }, - { url = "https://files.pythonhosted.org/packages/a0/4c/c02408042e6a7605ec063daed138e07b982fdb98467deaaf1c90950cf2c6/jiter-0.8.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2dd880785088ff2ad21ffee205e58a8c1ddabc63612444ae41e5e4b321b39c0", size = 342875 }, - { url = "https://files.pythonhosted.org/packages/91/61/c80ef80ed8a0a21158e289ef70dac01e351d929a1c30cb0f49be60772547/jiter-0.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:3ac9f578c46f22405ff7f8b1f5848fb753cc4b8377fbec8470a7dc3997ca7566", size = 202374 }, +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 }, + { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 }, + { url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097 }, + { url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603 }, + { url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625 }, + { url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832 }, + { url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590 }, + { url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690 }, + { url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649 }, + { url = "https://files.pythonhosted.org/packages/a6/e5/4e385945179bcf128fa10ad8dca9053d717cbe09e258110e39045c881fe5/jiter-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", size = 206920 }, + { url = "https://files.pythonhosted.org/packages/4c/47/5e0b94c603d8e54dd1faab439b40b832c277d3b90743e7835879ab663757/jiter-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", size = 210119 }, + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, ] [[package]] @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "0.3.41" +version = "0.3.45" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -591,9 +591,9 @@ dependencies = [ { name = "tenacity" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/0a/aa5167a1a46094024b8fe50917e37f1df5bcd0034adb25452e121dae60e6/langchain_core-0.3.41.tar.gz", hash = "sha256:d3ee9f3616ebbe7943470ade23d4a04e1729b1512c0ec55a4a07bd2ac64dedb4", size = 528826 } +sdist = { url = "https://files.pythonhosted.org/packages/38/85/4c896de87bc20915c5bf7be32fd325f4dfdbd0b262b4e49597a11d909ef7/langchain_core-0.3.45.tar.gz", hash = "sha256:a39b8446495d1ea97311aa726478c0a13ef1d77cb7644350bad6d9d3c0141a0c", size = 529844 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a6/551de93e02b1ef4ec031f6e1c0ff31a70790096c1e7066168a7693e4efe5/langchain_core-0.3.41-py3-none-any.whl", hash = "sha256:1a27cca5333bae7597de4004fb634b5f3e71667a3da6493b94ce83bcf15a23bd", size = 415149 }, + { url = "https://files.pythonhosted.org/packages/b6/8b/c41dbe55a502b71d84734be75c40d5a62fd6024dd8417c9ca2417e308782/langchain_core-0.3.45-py3-none-any.whl", hash = "sha256:fe560d644c102c3f5dcfb44eb5295e26d22deab259fdd084f6b1b55a0350b77c", size = 415868 }, ] [[package]] @@ -625,7 +625,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.3.11" +version = "0.3.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -636,9 +636,9 @@ dependencies = [ { name = "requests-toolbelt" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/34/c4c0eddad03e00457cd6be1a88c288cd4419da8d368d8f519a29abe5392c/langsmith-0.3.11.tar.gz", hash = "sha256:ddf29d24352e99de79c9618aaf95679214324e146c5d3d9475a7ddd2870018b1", size = 323815 } +sdist = { url = "https://files.pythonhosted.org/packages/26/92/03d93ff03281564701c330b9c7fe1078d8ff255834b044294accae8d817e/langsmith-0.3.14.tar.gz", hash = "sha256:54d9f74015bc533201b945ed03de8f45d9cb9cca6e63c58d7d3d277515d4c338", size = 326899 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/68/514ffa62860202a5a0a3acbf5c05017ef9df38d4437d2cb44a3cf93d617b/langsmith-0.3.11-py3-none-any.whl", hash = "sha256:0cca22737ef07d3b038a437c141deda37e00add56022582680188b681bec095e", size = 335265 }, + { url = "https://files.pythonhosted.org/packages/6a/74/9b7990a7127f367065a03f6eee5294da24a67867e0fc22487da20da82951/langsmith-0.3.14-py3-none-any.whl", hash = "sha256:1c3565aa5199c7ef40a21898ea9a9132fb3c0dae1d4dafc76ef27a83c5807a2f", size = 340028 }, ] [[package]] @@ -656,7 +656,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.3.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -668,9 +668,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/81e5f2490290351fc97bf46c24ff935128cb7d34d68e3987b522f26f7ada/mcp-1.3.0.tar.gz", hash = "sha256:f409ae4482ce9d53e7ac03f3f7808bcab735bdfc0fba937453782efb43882d45", size = 150235 } +sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/d2/a9e87b506b2094f5aa9becc1af5178842701b27217fa43877353da2577e3/mcp-1.3.0-py3-none-any.whl", hash = "sha256:2829d67ce339a249f803f22eba5e90385eafcac45c94b00cab6cef7e8f217211", size = 70672 }, + { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, ] [[package]] @@ -737,7 +737,7 @@ wheels = [ [[package]] name = "openai" -version = "1.65.4" +version = "1.66.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -749,9 +749,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/8d/1f7aace801afbbe4d6b8c7fa89b76eb9a3a8eeff38b84d4005d47b226b30/openai-1.65.4.tar.gz", hash = "sha256:0b08c58625d556f5c6654701af1023689c173eb0989ce8f73c7fd0eb22203c76", size = 359365 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/77/5172104ca1df35ed2ed8fb26dbc787f721c39498fc51d666c4db07756a0c/openai-1.66.3.tar.gz", hash = "sha256:8dde3aebe2d081258d4159c4cb27bdc13b5bb3f7ea2201d9bd940b9a89faf0c9", size = 397244 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/db/7bab832be24631a793492c1c61ecbf029018b99696f435db3b63d690bf1c/openai-1.65.4-py3-none-any.whl", hash = "sha256:15566d46574b94eae3d18efc2f9a4ebd1366d1d44bfc1bdafeea7a5cf8271bcb", size = 473523 }, + { url = "https://files.pythonhosted.org/packages/78/5a/e20182f7b6171642d759c548daa0ba20a1d3ac10d2bd0a13fd75704a9ac3/openai-1.66.3-py3-none-any.whl", hash = "sha256:a427c920f727711877ab17c11b95f1230b27767ba7a01e5b66102945141ceca9", size = 567400 }, ] [[package]] @@ -857,7 +857,7 @@ wheels = [ [[package]] name = "posthog" -version = "3.18.1" +version = "3.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -867,9 +867,9 @@ dependencies = [ { name = "requests" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/1c/aa6bb26491108e9e350cd7af4d4b0a54d48c755cc76b2c2d90ef2916b8b3/posthog-3.18.1.tar.gz", hash = "sha256:ce115b8422f26c57cd4143499115b741f5683c93d0b5b87bab391579aaef084b", size = 65573 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/cf/26b79a2c52d4bc868962864766618cfbfee1ea77ac5ac860fc5280dbc3c1/posthog-3.20.0.tar.gz", hash = "sha256:7933f7c98c0152a34e387e441fefdc62e2b86aade5dea94dc6ecbe7358138828", size = 67446 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/c2/407c8cf3edf4fe33b82de3fee11178d083ee0b6e3eb28ff8072caaa85907/posthog-3.18.1-py2.py3-none-any.whl", hash = "sha256:6865104b7cf3a5b13949e2bc2aab9b37b5fbf5f9e045fa55b9eabe21b3850200", size = 76762 }, + { url = "https://files.pythonhosted.org/packages/64/06/0ff327efea25642a96f2b29ce054ad1eba809bb347fab02da8a2410abb04/posthog-3.20.0-py2.py3-none-any.whl", hash = "sha256:ce3aa75a39c36bc3af2b6947757493e6c7d021fe5088b185d3277157770d4ef4", size = 79291 }, ] [[package]] @@ -1151,11 +1151,11 @@ wheels = [ [[package]] name = "setuptools" -version = "75.8.2" +version = "76.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 } +sdist = { url = "https://files.pythonhosted.org/packages/32/d2/7b171caf085ba0d40d8391f54e1c75a1cda9255f542becf84575cfd8a732/setuptools-76.0.0.tar.gz", hash = "sha256:43b4ee60e10b0d0ee98ad11918e114c70701bc6051662a9a675a0496c1a158f4", size = 1349387 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 }, + { url = "https://files.pythonhosted.org/packages/37/66/d2d7e6ad554f3a7c7297c3f8ef6e22643ad3d35ef5c63bf488bc89f32f31/setuptools-76.0.0-py3-none-any.whl", hash = "sha256:199466a166ff664970d0ee145839f5582cb9bca7a0a3a2e795b6a9cb2308e9c6", size = 1236106 }, ] [[package]] @@ -1200,14 +1200,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.0" +version = "0.46.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/b6/fb9a32e3c5d59b1e383c357534c63c2d3caa6f25bf3c59dd89d296ecbaec/starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50", size = 2575568 } +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/94/8af675a62e3c91c2dee47cf92e602cfac86e8767b1a1ac3caf1b327c2ab0/starlette-0.46.0-py3-none-any.whl", hash = "sha256:913f0798bd90ba90a9156383bcf1350a17d6259451d0d8ee27fc0cf2db609038", size = 71991 }, + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, ] [[package]] From 00a01742589f63c816de16d2fc0c1f1c50e0d7fc Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Fri, 14 Mar 2025 21:42:03 +0100 Subject: [PATCH 3/4] linting fixes --- src/browser_use_mcp_server/server.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py index 9e55d3b..fec5824 100644 --- a/src/browser_use_mcp_server/server.py +++ b/src/browser_use_mcp_server/server.py @@ -138,9 +138,8 @@ async def reset_browser_context(context: BrowserContext) -> None: # If we get here, we need to reinitialize the browser try: # Get the original configuration from the context if possible - config = None if hasattr(context, "config"): - config = context.config + pass # Reinitialize the browser with the same configuration browser_config = BrowserConfig( From eab153ef7ff5407c9f97c968a6de74aa4d214031 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Tue, 18 Mar 2025 19:27:30 +0100 Subject: [PATCH 4/4] commit: Fix PR feedback issues - Removed extra empty lines in Dockerfile (lines 16, 69, 72) - Implemented Docker secrets for VNC password with fallback mechanism - Created centralized environment variable handling with init_configuration() - Fixed import errors by updating to langchain_core - Kept Any type annotations where browser-use specific types weren't accessible - Added server startup sanity checks for port, dimensions, expiry time - Removed redundant src directory - Updated README with Docker secrets instructions --- Dockerfile | 12 +- README.md | 9 +- server/server.py | 132 ++-- src/browser_use_mcp_server/__init__.py | 25 - src/browser_use_mcp_server/__main__.py | 9 - src/browser_use_mcp_server/cli.py | 349 ---------- src/browser_use_mcp_server/server.py | 897 ------------------------- 7 files changed, 94 insertions(+), 1339 deletions(-) delete mode 100644 src/browser_use_mcp_server/__init__.py delete mode 100644 src/browser_use_mcp_server/__main__.py delete mode 100644 src/browser_use_mcp_server/cli.py delete mode 100644 src/browser_use_mcp_server/server.py diff --git a/Dockerfile b/Dockerfile index 7def187..a36a13d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ RUN apt-get update -y && \ # Install Python before the project for caching RUN uv python install 3.13 - WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ @@ -25,7 +24,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM debian:bookworm-slim AS runtime -ARG VNC_PASSWORD="browser-use" +# VNC password will be read from Docker secrets or fallback to default +# Create a fallback default password file +RUN mkdir -p /run/secrets && \ + echo "browser-use" > /run/secrets/vnc_password_default # Install required packages including Chromium and clean up in the same layer RUN apt-get update && \ @@ -59,17 +61,13 @@ ENV PATH="/app/.venv/bin:$PATH" \ # Combine VNC setup commands to reduce layers RUN mkdir -p ~/.vnc && \ - echo $VNC_PASSWORD | vncpasswd -f > /root/.vnc/passwd && \ - chmod 600 /root/.vnc/passwd && \ printf '#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\nstartxfce4' > /root/.vnc/xstartup && \ chmod +x /root/.vnc/xstartup && \ - printf '#!/bin/bash\nvncserver -depth 24 -geometry 1920x1080 -localhost no -PasswordFile /root/.vnc/passwd :0\nproxy-login-automator\npython /app/server --port 8000' > /app/boot.sh && \ + 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 && \ chmod +x /app/boot.sh - RUN playwright install --with-deps --no-shell chromium - EXPOSE 8000 ENTRYPOINT ["/bin/bash", "/app/boot.sh"] diff --git a/README.md b/README.md index ac3a1aa..9b1b9ad 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,15 @@ CHROME_PATH=[only change this if you have a custom chrome build] - we will be adding support for other LLM providers to power browser-use (claude, grok, bedrock, etc) -when building the dockerfile you can add in your own VNC server password: +when building the docker image, you can use Docker secrets for VNC password: ``` -docker build --build-arg VNC_PASSWORD=klaatubaradanikto . +# With Docker secrets (recommended for production) +echo "your-secure-password" > vnc_password.txt +docker run -v $(pwd)/vnc_password.txt:/run/secrets/vnc_password your-image-name + +# Or during development with the default password +docker build . ``` ### tools diff --git a/server/server.py b/server/server.py index b742aea..301e585 100644 --- a/server/server.py +++ b/server/server.py @@ -29,11 +29,12 @@ from browser_use.browser.context import BrowserContext, BrowserContextConfig # MCP server components -from mcp.server.lowlevel import Server +from mcp.server import Server import mcp.types as types # LLM provider from langchain_openai import ChatOpenAI +from langchain_core.language_models import BaseLanguageModel # Configure logging logging.basicConfig(level=logging.INFO) @@ -42,24 +43,48 @@ # Load environment variables load_dotenv() -# Constants -DEFAULT_WINDOW_WIDTH = 1280 -DEFAULT_WINDOW_HEIGHT = 1100 -DEFAULT_LOCALE = "en-US" -DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" -DEFAULT_TASK_EXPIRY_MINUTES = 60 -DEFAULT_ESTIMATED_TASK_SECONDS = 60 -CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour -MAX_AGENT_STEPS = 10 - -# Browser configuration arguments -BROWSER_ARGS = [ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=0", # Use random port to avoid conflicts -] + +def init_configuration() -> Dict[str, any]: + """ + Initialize configuration from environment variables with defaults. + + Returns: + Dictionary containing all configuration parameters + """ + config = { + # Browser window settings + "DEFAULT_WINDOW_WIDTH": int(os.environ.get("BROWSER_WINDOW_WIDTH", 1280)), + "DEFAULT_WINDOW_HEIGHT": int(os.environ.get("BROWSER_WINDOW_HEIGHT", 1100)), + # Browser config settings + "DEFAULT_LOCALE": os.environ.get("BROWSER_LOCALE", "en-US"), + "DEFAULT_USER_AGENT": os.environ.get( + "BROWSER_USER_AGENT", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", + ), + # Task settings + "DEFAULT_TASK_EXPIRY_MINUTES": int(os.environ.get("TASK_EXPIRY_MINUTES", 60)), + "DEFAULT_ESTIMATED_TASK_SECONDS": int( + os.environ.get("ESTIMATED_TASK_SECONDS", 60) + ), + "CLEANUP_INTERVAL_SECONDS": int( + os.environ.get("CLEANUP_INTERVAL_SECONDS", 3600) + ), # 1 hour + "MAX_AGENT_STEPS": int(os.environ.get("MAX_AGENT_STEPS", 10)), + # Browser arguments + "BROWSER_ARGS": [ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=0", # Use random port to avoid conflicts + ], + } + + return config + + +# Initialize configuration +CONFIG = init_configuration() # Task storage for async operations task_store: Dict[str, Dict[str, Any]] = {} @@ -67,9 +92,9 @@ async def create_browser_context_for_task( chrome_path: Optional[str] = None, - window_width: int = DEFAULT_WINDOW_WIDTH, - window_height: int = DEFAULT_WINDOW_HEIGHT, - locale: str = DEFAULT_LOCALE, + window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], + window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], + locale: str = CONFIG["DEFAULT_LOCALE"], ) -> Tuple[Browser, BrowserContext]: """ Create a fresh browser and context for a task. @@ -92,7 +117,7 @@ async def create_browser_context_for_task( try: # Create browser configuration browser_config = BrowserConfig( - extra_chromium_args=BROWSER_ARGS, + extra_chromium_args=CONFIG["BROWSER_ARGS"], ) # Set chrome path if provided @@ -109,7 +134,7 @@ async def create_browser_context_for_task( minimum_wait_page_load_time=0.2, browser_window_size={"width": window_width, "height": window_height}, locale=locale, - user_agent=DEFAULT_USER_AGENT, + user_agent=CONFIG["DEFAULT_USER_AGENT"], highlight_elements=True, viewport_expansion=0, ) @@ -127,10 +152,10 @@ async def run_browser_task_async( task_id: str, url: str, action: str, - llm: Any, - window_width: int = DEFAULT_WINDOW_WIDTH, - window_height: int = DEFAULT_WINDOW_HEIGHT, - locale: str = DEFAULT_LOCALE, + llm: BaseLanguageModel, + window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], + window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], + locale: str = CONFIG["DEFAULT_LOCALE"], ) -> None: """ Run a browser task asynchronously and store the result. @@ -220,7 +245,7 @@ async def done_callback(history: Any) -> None: ) # Run the agent with a reasonable step limit - agent_result = await agent.run(max_steps=MAX_AGENT_STEPS) + agent_result = await agent.run(max_steps=CONFIG["MAX_AGENT_STEPS"]) # Get the final result final_result = agent_result.final_result() @@ -294,7 +319,7 @@ async def cleanup_old_tasks() -> None: while True: try: # Sleep first to avoid cleaning up tasks too early - await asyncio.sleep(CLEANUP_INTERVAL_SECONDS) + await asyncio.sleep(CONFIG["CLEANUP_INTERVAL_SECONDS"]) current_time = datetime.now() tasks_to_remove = [] @@ -323,11 +348,11 @@ async def cleanup_old_tasks() -> None: def create_mcp_server( - llm: Any, - task_expiry_minutes: int = DEFAULT_TASK_EXPIRY_MINUTES, - window_width: int = DEFAULT_WINDOW_WIDTH, - window_height: int = DEFAULT_WINDOW_HEIGHT, - locale: str = DEFAULT_LOCALE, + llm: BaseLanguageModel, + task_expiry_minutes: int = CONFIG["DEFAULT_TASK_EXPIRY_MINUTES"], + window_width: int = CONFIG["DEFAULT_WINDOW_WIDTH"], + window_height: int = CONFIG["DEFAULT_WINDOW_HEIGHT"], + locale: str = CONFIG["DEFAULT_LOCALE"], ) -> Server: """ Create and configure an MCP server for browser interaction. @@ -403,8 +428,8 @@ async def call_tool( { "task_id": task_id, "status": "pending", - "message": f"Browser task started. Please wait for {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.", - "estimated_time": f"{DEFAULT_ESTIMATED_TASK_SECONDS} seconds", + "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.", + "estimated_time": f"{CONFIG['DEFAULT_ESTIMATED_TASK_SECONDS']} seconds", "resource_uri": f"resource://browser_task/{task_id}", "sleep_command": "sleep 5", "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.", @@ -577,29 +602,21 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--chrome-path", - default=None, - help="Path to Chrome executable", -) +@click.option("--chrome-path", default=None, help="Path to Chrome executable") @click.option( "--window-width", - default=DEFAULT_WINDOW_WIDTH, + default=CONFIG["DEFAULT_WINDOW_WIDTH"], help="Browser window width", ) @click.option( "--window-height", - default=DEFAULT_WINDOW_HEIGHT, + default=CONFIG["DEFAULT_WINDOW_HEIGHT"], help="Browser window height", ) -@click.option( - "--locale", - default=DEFAULT_LOCALE, - help="Browser locale", -) +@click.option("--locale", default=CONFIG["DEFAULT_LOCALE"], help="Browser locale") @click.option( "--task-expiry-minutes", - default=DEFAULT_TASK_EXPIRY_MINUTES, + default=CONFIG["DEFAULT_TASK_EXPIRY_MINUTES"], help="Minutes after which tasks are considered expired", ) def main( @@ -683,6 +700,21 @@ async def startup_event(): """Initialize the server on startup.""" logger.info("Starting MCP server...") + # Sanity checks for critical configuration + if port <= 0 or port > 65535: + logger.error(f"Invalid port number: {port}") + raise ValueError(f"Invalid port number: {port}") + + if window_width <= 0 or window_height <= 0: + logger.error(f"Invalid window dimensions: {window_width}x{window_height}") + raise ValueError( + f"Invalid window dimensions: {window_width}x{window_height}" + ) + + if task_expiry_minutes <= 0: + logger.error(f"Invalid task expiry minutes: {task_expiry_minutes}") + raise ValueError(f"Invalid task expiry minutes: {task_expiry_minutes}") + # Start background task cleanup asyncio.create_task(app.cleanup_old_tasks()) logger.info("Task cleanup process scheduled") diff --git a/src/browser_use_mcp_server/__init__.py b/src/browser_use_mcp_server/__init__.py deleted file mode 100644 index 35271de..0000000 --- a/src/browser_use_mcp_server/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Browser Use MCP Server - A library for integrating browser-use with MCP. -""" - -__version__ = "0.1.2" - -from .server import ( - BrowserContext, - BrowserContextConfig, - initialize_browser_context, - run_browser_task_async, - check_browser_health, - reset_browser_context, - create_mcp_server, -) - -__all__ = [ - "BrowserContext", - "BrowserContextConfig", - "initialize_browser_context", - "run_browser_task_async", - "check_browser_health", - "reset_browser_context", - "create_mcp_server", -] diff --git a/src/browser_use_mcp_server/__main__.py b/src/browser_use_mcp_server/__main__.py deleted file mode 100644 index 1a72618..0000000 --- a/src/browser_use_mcp_server/__main__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Main module for browser-use-mcp-server. -""" - -import sys -from .cli import cli - -if __name__ == "__main__": - sys.exit(cli()) diff --git a/src/browser_use_mcp_server/cli.py b/src/browser_use_mcp_server/cli.py deleted file mode 100644 index 6aa0163..0000000 --- a/src/browser_use_mcp_server/cli.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -Command-line interface for browser-use-mcp-server. -""" - -import os -import click -import asyncio -import logging -import uvicorn -from starlette.applications import Starlette -from starlette.routing import Route -from starlette.responses import JSONResponse, StreamingResponse -from typing import Dict, Any, Optional -import sys - -from langchain_openai import ChatOpenAI -from mcp.server.lowlevel import Server - -from .server import ( - initialize_browser_context, - create_mcp_server, - check_browser_health, - reset_browser_context, -) - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class AsyncStdinReader: - """Async wrapper for stdin.""" - - async def receive(self) -> bytes: - line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) - return line.encode() - - -class AsyncStdoutWriter: - """Async wrapper for stdout.""" - - async def send(self, data: bytes) -> None: - text = data.decode() - sys.stdout.write(text) - sys.stdout.flush() - await asyncio.sleep(0) # Yield control back to the event loop - - -@click.group() -def cli(): - """Browser MCP Server CLI.""" - pass - - -@cli.command() -@click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - help="Transport type", -) -@click.option( - "--chrome-path", - default=None, - help="Path to Chrome executable (defaults to CHROME_PATH env var)", -) -@click.option( - "--model", - default="gpt-4o", - help="OpenAI model to use", -) -@click.option( - "--window-width", - default=1280, - help="Browser window width", -) -@click.option( - "--window-height", - default=1100, - help="Browser window height", -) -@click.option( - "--task-expiry-minutes", - default=60, - help="Minutes after which tasks expire", -) -def start( - port: int, - transport: str, - chrome_path: Optional[str], - model: str, - window_width: int, - window_height: int, - task_expiry_minutes: int, -) -> int: - """Start the browser MCP server.""" - # Record tasks for SSE transport - task_store: Dict[str, Any] = {} - - # Set up browser context and LLM - if chrome_path is None: - chrome_path = os.environ.get("CHROME_PATH") - - try: - logger.info( - f"Initializing browser context with Chrome path: {chrome_path or 'default'}" - ) - context = initialize_browser_context( - chrome_path=chrome_path, - window_width=window_width, - window_height=window_height, - ) - logger.info("Browser context initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize browser context: {e}") - return 1 - - try: - logger.info(f"Initializing LLM with model: {model}") - llm = ChatOpenAI(model=model, temperature=0.0) - logger.info("LLM initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize LLM: {e}") - return 1 - - try: - # Create MCP server - logger.info("Creating MCP server") - app = create_mcp_server( - context=context, - llm=llm, - custom_task_store=task_store, - task_expiry_minutes=task_expiry_minutes, - ) - logger.info("MCP server created successfully") - except Exception as e: - logger.error(f"Failed to create MCP server: {e}") - return 1 - - if transport == "stdio": - # Run the server with stdio transport - logger.info("Starting browser MCP server with stdio transport") - return asyncio.run(_run_stdio(app)) - - else: - # Set up Starlette app for SSE transport - async def handle_sse(request): - """Handle SSE connections.""" - logger.info(f"New SSE connection from {request.client}") - logger.info(f"Request headers: {request.headers}") - - # Create a queue for sending messages - send_queue = asyncio.Queue() - - # Define message handlers for MCP server - class SSEReadStream: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - async def receive(self) -> bytes: - # For SSE, we don't receive anything from client - # Just block indefinitely - future = asyncio.Future() - await future # This will block forever - return b"" # Never reached - - class SSEWriteStream: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - async def send(self, data: bytes) -> None: - # Queue the message to be sent over SSE - await send_queue.put(data) - - # Create async generator to stream SSE responses - async def stream_response(): - """Stream SSE responses.""" - logger.info("Setting up SSE stream") - - # Start MCP server in background - read_stream = SSEReadStream() - write_stream = SSEWriteStream() - - server_task = asyncio.create_task( - app.run( - read_stream=read_stream, - write_stream=write_stream, - initialization_options={}, - ) - ) - - try: - # Send initial connected event - logger.info("Sending initial connected event") - yield b"event: connected\ndata: {}\n\n" - - # Stream messages from the queue - logger.info("Starting to stream messages") - while True: - message = await send_queue.get() - logger.info(f"Sending message: {message[:100]}...") - data = f"data: {message.decode()}\n\n" - yield data.encode("utf-8") - send_queue.task_done() - except Exception as e: - logger.error(f"SSE streaming error: {e}") - finally: - # Clean up - server_task.cancel() - logger.info("SSE connection closed") - - return StreamingResponse( - stream_response(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, - ) - - async def health_check(request): - """Health check endpoint.""" - try: - # Check browser health - healthy = await check_browser_health(context) - return JSONResponse({"status": "healthy" if healthy else "unhealthy"}) - except Exception as e: - return JSONResponse( - {"status": "error", "message": str(e)}, status_code=500 - ) - - async def reset_context(request): - """Reset browser context endpoint.""" - try: - # Reset browser context - await reset_browser_context(context) - return JSONResponse( - {"status": "success", "message": "Browser context reset"} - ) - except Exception as e: - return JSONResponse( - {"status": "error", "message": str(e)}, status_code=500 - ) - - # Define startup and shutdown events - async def startup_event(): - """Run on server startup.""" - logger.info("Starting server...") - - # Start task cleanup job - asyncio.create_task(cleanup_old_tasks()) - - logger.info(f"Server started on port {port}") - - async def shutdown_event(): - """Run on server shutdown.""" - logger.info("Shutting down server...") - - try: - # Close the browser - await context.browser.close() - logger.info("Browser closed successfully") - except Exception as e: - logger.error(f"Error closing browser: {e}") - - logger.info("Server shut down") - - async def cleanup_old_tasks(): - """Periodically clean up expired tasks.""" - from datetime import datetime - - while True: - try: - # Check for expired tasks every minute - await asyncio.sleep(60) - - # Get current time - now = datetime.now() - - # Check each task - expired_tasks = [] - for task_id, task in task_store.items(): - if "expiry_time" in task: - # Parse expiry time - expiry_time = datetime.fromisoformat(task["expiry_time"]) - - # Check if expired - if now > expiry_time: - expired_tasks.append(task_id) - - # Remove expired tasks - for task_id in expired_tasks: - logger.info(f"Removing expired task {task_id}") - task_store.pop(task_id, None) - - except Exception as e: - logger.error(f"Error cleaning up old tasks: {e}") - - # Create Starlette app with routes - routes = [ - Route("/sse", endpoint=handle_sse, methods=["GET"]), - Route("/health", endpoint=health_check, methods=["GET"]), - Route("/reset", endpoint=reset_context, methods=["POST"]), - ] - - starlette_app = Starlette( - routes=routes, - on_startup=[startup_event], - on_shutdown=[shutdown_event], - debug=True, - ) - - # Run with uvicorn - logger.info(f"Starting browser MCP server with SSE transport on port {port}") - uvicorn.run(starlette_app, host="0.0.0.0", port=port) - - return 0 - - -async def _run_stdio(app: Server) -> int: - """Run the server using stdio transport.""" - try: - stdin_reader = AsyncStdinReader() - stdout_writer = AsyncStdoutWriter() - - # Create initialization options - initialization_options = {} - - # Run the server - await app.run( - read_stream=stdin_reader, - write_stream=stdout_writer, - initialization_options=initialization_options, - ) - return 0 - except KeyboardInterrupt: - logger.info("Server stopped by user") - return 0 - except Exception as e: - logger.error(f"Error running server: {e}") - return 1 - - -if __name__ == "__main__": - cli() diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py deleted file mode 100644 index fec5824..0000000 --- a/src/browser_use_mcp_server/server.py +++ /dev/null @@ -1,897 +0,0 @@ -""" -Core functionality for integrating browser-use with MCP. - -This module provides the core components for integrating browser-use with the -Model-Control-Protocol (MCP) server. It supports browser automation via SSE transport. -""" - -import os -import asyncio -import uuid -from datetime import datetime, timedelta -from typing import Any, Callable, Dict, List, Optional, Union, Awaitable - -from browser_use import Agent -from browser_use.browser.browser import Browser, BrowserConfig -from browser_use.browser.context import BrowserContextConfig, BrowserContext -import mcp.types as types -from mcp.server.lowlevel import Server - -import logging -from dotenv import load_dotenv -import inspect - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Load environment variables -load_dotenv() - -# Task storage for async operations -task_store = {} - -# Flag to track browser context health -browser_context_healthy = True - - -class MockContext: - """Mock context for testing.""" - - def __init__(self): - pass - - -class MockLLM: - """Mock LLM for testing.""" - - def __init__(self): - pass - - # Define any necessary methods for testing here - - -def initialize_browser_context( - chrome_path: Optional[str] = None, - window_width: int = 1280, - window_height: int = 1100, - locale: str = "en-US", - user_agent: Optional[str] = None, - extra_chromium_args: Optional[List[str]] = None, -) -> BrowserContext: - """ - Initialize the browser context with specified parameters. - - Args: - chrome_path: Path to Chrome instance - window_width: Browser window width - window_height: Browser window height - locale: Browser locale - user_agent: Browser user agent - extra_chromium_args: Additional arguments for Chrome - - Returns: - Initialized BrowserContext - """ - # Browser context configuration - if not user_agent: - user_agent = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" - ) - - if not extra_chromium_args: - extra_chromium_args = [ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=9222", - ] - - config = BrowserContextConfig( - wait_for_network_idle_page_load_time=0.6, - maximum_wait_page_load_time=1.2, - minimum_wait_page_load_time=0.2, - browser_window_size={"width": window_width, "height": window_height}, - locale=locale, - user_agent=user_agent, - highlight_elements=True, - viewport_expansion=0, - ) - - # Initialize browser and context - browser = Browser( - config=BrowserConfig( - chrome_instance_path=chrome_path or os.environ.get("CHROME_PATH"), - extra_chromium_args=extra_chromium_args, - ) - ) - - return BrowserContext(browser=browser, config=config) - - -async def reset_browser_context(context: BrowserContext) -> None: - """ - Reset the browser context to a clean state. - - Args: - context: The browser context to reset - """ - global browser_context_healthy - - try: - logger.info("Resetting browser context...") - - # Check if browser is closed - try: - # Try a simple operation to check if browser is still alive - if hasattr(context.browser, "new_context"): - await context.browser.new_context() - browser_context_healthy = True - logger.info("Browser context reset successfully") - return - except Exception as e: - logger.warning(f"Browser context appears to be closed: {e}") - # Continue with reinitializing the browser - - # If we get here, we need to reinitialize the browser - try: - # Get the original configuration from the context if possible - if hasattr(context, "config"): - pass - - # Reinitialize the browser with the same configuration - browser_config = BrowserConfig( - chrome_path=os.environ.get("CHROME_PATH"), - extra_chromium_args=[ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=9222", - ], - ) - - # Create a new browser instance - browser = Browser(config=browser_config) - await browser.initialize() - - # Create a new context with the same configuration as before - context_config = BrowserContextConfig( - wait_for_network_idle_page_load_time=0.6, - maximum_wait_page_load_time=1.2, - minimum_wait_page_load_time=0.2, - browser_window_size={"width": 1280, "height": 1100}, - locale="en-US", - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", - highlight_elements=True, - viewport_expansion=0, - ) - - # Replace the old browser with the new one - context.browser = browser - context.config = context_config - - # Create a new browser context - await context.browser.new_context(context_config) - - logger.info("Browser reinitialized successfully") - browser_context_healthy = True - return - except Exception as e: - logger.error(f"Failed to reinitialize browser: {e}") - browser_context_healthy = False - raise - - except Exception as e: - browser_context_healthy = False - logger.error(f"Failed to reset browser context: {e}") - # Re-raise to allow caller to handle - raise - - -async def check_browser_health(context: BrowserContext) -> bool: - """ - Check if the browser context is healthy. - - Args: - context: The browser context to check - - Returns: - True if healthy, False otherwise - """ - global browser_context_healthy - - # First, check if the browser context is already marked as unhealthy - if not browser_context_healthy: - logger.info("Browser context marked as unhealthy, attempting reset...") - try: - await reset_browser_context(context) - logger.info("Browser context successfully reset") - return True - except Exception as e: - logger.error(f"Failed to recover browser context: {e}") - return False - - # Try a simple operation to check if browser is still alive - try: - # Check if browser is still responsive - if hasattr(context.browser, "new_context"): - # Just check if the method exists, don't actually call it - browser_context_healthy = True - logger.debug("Browser context appears healthy") - else: - # If the method doesn't exist, mark as unhealthy - logger.warning("Browser context missing expected methods") - browser_context_healthy = False - except Exception as e: - logger.warning(f"Error checking browser health: {e}") - browser_context_healthy = False - - # If marked as unhealthy, try to reset - if not browser_context_healthy: - logger.info("Browser context appears unhealthy, attempting reset...") - try: - await reset_browser_context(context) - logger.info("Browser context successfully reset") - return True - except Exception as e: - logger.error(f"Failed to recover browser context: {e}") - return False - - return True - - -async def run_browser_task_async( - context: BrowserContext, - llm: Any, - task_id: str, - url: str, - action: str, - custom_task_store: Optional[Dict[str, Any]] = None, - step_callback: Optional[ - Callable[[Dict[str, Any], Dict[str, Any], int], Awaitable[None]] - ] = None, - done_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, - task_expiry_minutes: int = 60, - max_retries: int = 1, -) -> str: - """ - Run a browser task asynchronously. - - Args: - context: Browser context for the task - llm: Language model to use for the agent - task_id: Unique identifier for the task - url: URL to navigate to - action: Action description for the agent - custom_task_store: Optional custom task store for tracking tasks - step_callback: Optional callback for each step of the task - done_callback: Optional callback for when the task is complete - task_expiry_minutes: Minutes after which the task is considered expired - max_retries: Maximum number of retries if the task fails due to browser issues - - Returns: - Task ID - """ - store = custom_task_store if custom_task_store is not None else task_store - - # Define steps for tracking progress - store[task_id] = { - "id": task_id, - "url": url, - "action": action, - "status": "running", - "start_time": datetime.now().isoformat(), - "expiry_time": ( - datetime.now() + timedelta(minutes=task_expiry_minutes) - ).isoformat(), - "steps": [], - "result": None, - "error": None, - } - - # Define default callbacks if not provided - async def default_step_callback(browser_state, agent_output, step_number): - """Default step callback that updates the task store.""" - store[task_id]["steps"].append( - { - "step": step_number, - "browser_state": browser_state, - "agent_output": agent_output, - "timestamp": datetime.now().isoformat(), - } - ) - logger.info(f"Task {task_id}: Step {step_number} completed") - - async def default_done_callback(history): - """Default done callback that updates the task store.""" - store[task_id]["status"] = "completed" - store[task_id]["result"] = history - store[task_id]["end_time"] = datetime.now().isoformat() - logger.info(f"Task {task_id}: Completed successfully") - - step_cb = step_callback if step_callback is not None else default_step_callback - done_cb = done_callback if done_callback is not None else default_done_callback - - retries = 0 - while retries <= max_retries: - try: - # Check and ensure browser health - browser_healthy = await check_browser_health(context) - if not browser_healthy: - raise Exception("Browser context is unhealthy") - - # Create agent and run task - try: - # Inspect Agent class initialization parameters - agent_params = inspect.signature(Agent.__init__).parameters - logger.info(f"Agent init parameters: {list(agent_params.keys())}") - - # Adapt initialization based on available parameters - agent_kwargs = {"context": context} - - if "llm" in agent_params: - agent_kwargs["llm"] = llm - - # Add task parameter which is required based on the error message - if "task" in agent_params: - # Create a task that combines navigation and the action - task_description = f"First, navigate to {url}. Then, {action}" - agent_kwargs["task"] = task_description - - # Add browser and browser_context parameters if they're required - if "browser" in agent_params: - agent_kwargs["browser"] = context.browser - if "browser_context" in agent_params: - agent_kwargs["browser_context"] = context - - # Check for callbacks - if "step_callback" in agent_params: - agent_kwargs["step_callback"] = step_cb - if "done_callback" in agent_params: - agent_kwargs["done_callback"] = done_cb - - # Register callbacks with the new parameter names if the old ones don't exist - if ( - "step_callback" not in agent_params - and "register_new_step_callback" in agent_params - ): - agent_kwargs["register_new_step_callback"] = step_cb - if ( - "done_callback" not in agent_params - and "register_done_callback" in agent_params - ): - agent_kwargs["register_done_callback"] = done_cb - - # Check if all required parameters are set - missing_params = [] - for param_name, param in agent_params.items(): - if ( - param.default == inspect.Parameter.empty - and param_name != "self" - and param_name not in agent_kwargs - ): - missing_params.append(param_name) - - if missing_params: - logger.error( - f"Missing required parameters for Agent: {missing_params}" - ) - raise Exception( - f"Missing required parameters for Agent: {missing_params}" - ) - - # Create agent with appropriate parameters - agent = Agent(**agent_kwargs) - - # Launch task asynchronously - # Don't pass any parameters to run() as they should already be set via init - asyncio.create_task(agent.run()) - return task_id - except Exception as agent_error: - logger.error(f"Error creating Agent: {str(agent_error)}") - raise Exception(f"Failed to create browser agent: {str(agent_error)}") - - except Exception as e: - # Update task store with error - store[task_id]["error"] = str(e) - logger.error(f"Task {task_id}: Error - {str(e)}") - - # If we've reached max retries, mark as error and exit - if retries >= max_retries: - store[task_id]["status"] = "error" - store[task_id]["end_time"] = datetime.now().isoformat() - logger.error(f"Task {task_id}: Failed after {retries + 1} attempts") - raise - - # Otherwise, try to reset the browser context and retry - retries += 1 - logger.info(f"Task {task_id}: Retry attempt {retries}/{max_retries}") - - try: - # Reset browser context before retrying - await reset_browser_context(context) - logger.info(f"Task {task_id}: Browser context reset for retry") - except Exception as reset_error: - logger.error( - f"Task {task_id}: Failed to reset browser context: {str(reset_error)}" - ) - # Continue with retry even if reset fails - - # This should never be reached due to the raise in the loop - return task_id - - -def create_mcp_server( - context: BrowserContext, - llm: Any, - custom_task_store: Optional[Dict[str, Any]] = None, - task_expiry_minutes: int = 60, -) -> Server: - """ - Create an MCP server with browser capabilities. - - Args: - context: Browser context for the server - llm: Language model to use for the agent - custom_task_store: Optional custom task store for tracking tasks - task_expiry_minutes: Minutes after which tasks are considered expired - - Returns: - Configured MCP server - """ - # Use provided task store or default - store = custom_task_store if custom_task_store is not None else task_store - - # Create MCP server - app = Server(name="browser-use-mcp-server") - - @app.call_tool() - async def call_tool( - name: str, arguments: dict - ) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: - """ - Handle tool calls from the MCP client. - - Args: - name: Tool name - arguments: Tool arguments - - Returns: - List of content items - """ - logger.info(f"Tool call received: {name} with arguments: {arguments}") - - if name == "mcp__browser_navigate": - # Validate required arguments - if "url" not in arguments: - logger.error("URL argument missing in browser.navigate call") - return [types.TextContent(type="text", text="Error: URL is required")] - - url = arguments["url"] - action = arguments.get( - "action", "Navigate to the given URL and tell me what you see." - ) - - logger.info(f"Navigation request to URL: {url} with action: {action}") - - # Generate unique task ID - task_id = str(uuid.uuid4()) - - try: - # Run browser task - await run_browser_task_async( - context=context, - llm=llm, - task_id=task_id, - url=url, - action=action, - custom_task_store=store, - task_expiry_minutes=task_expiry_minutes, - ) - - logger.info(f"Navigation task {task_id} started successfully") - - # Return a simpler response with just TextContent to avoid validation errors - return [ - types.TextContent( - type="text", - text=f"Navigating to {url}. Task {task_id} started successfully. Results will be available when task completes.", - ) - ] - - except Exception as e: - logger.error(f"Error executing navigation task: {str(e)}") - return [ - types.TextContent( - type="text", text=f"Error navigating to {url}: {str(e)}" - ) - ] - - elif name == "mcp__browser_use": - # Validate required arguments - if "url" not in arguments: - logger.error("URL argument missing in browser_use call") - return [types.TextContent(type="text", text="Error: URL is required")] - - if "action" not in arguments: - logger.error("Action argument missing in browser_use call") - return [ - types.TextContent(type="text", text="Error: Action is required") - ] - - url = arguments["url"] - action = arguments["action"] - - logger.info(f"Browser use request to URL: {url} with action: {action}") - - # Generate unique task ID - task_id = str(uuid.uuid4()) - - try: - # Run browser task - await run_browser_task_async( - context=context, - llm=llm, - task_id=task_id, - url=url, - action=action, - custom_task_store=store, - task_expiry_minutes=task_expiry_minutes, - ) - - logger.info(f"Browser task {task_id} started successfully") - - # Return task ID for async execution - return [ - types.TextContent( - type="text", - text=f"Task {task_id} started. Use mcp__browser_get_result with this task ID to get results when complete.", - ) - ] - - except Exception as e: - logger.error(f"Error executing browser task: {str(e)}") - return [ - types.TextContent( - type="text", text=f"Error executing browser task: {str(e)}" - ) - ] - - elif name == "mcp__browser_get_result": - # Validate required arguments - if "task_id" not in arguments: - logger.error("Task ID argument missing in browser_get_result call") - return [ - types.TextContent(type="text", text="Error: Task ID is required") - ] - - task_id = arguments["task_id"] - logger.info(f"Result request for task: {task_id}") - - # Check if task exists - if task_id not in store: - return [ - types.TextContent( - type="text", text=f"Error: Task {task_id} not found" - ) - ] - - task = store[task_id] - - # Check task status - if task["status"] == "error": - return [types.TextContent(type="text", text=f"Error: {task['error']}")] - - if task["status"] == "running": - # For running tasks, return the steps completed so far - steps_text = "\n".join( - [ - f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" - for s in task["steps"] - ] - ) - return [ - types.TextContent( - type="text", - text=f"Task {task_id} is still running.\n\nSteps completed so far:\n{steps_text}", - ) - ] - - # For completed tasks, return the full result - if task["result"]: - # Format the result as text - result_text = "Task completed successfully.\n\n" - result_text += f"URL: {task['url']}\n\n" - result_text += f"Action: {task['action']}\n\n" - - # Add final result if available - if isinstance(task["result"], dict) and "text" in task["result"]: - result_text += f"Result: {task['result']['text']}\n\n" - elif isinstance(task["result"], str): - result_text += f"Result: {task['result']}\n\n" - else: - # Try to extract result from the last step - if task["steps"] and task["steps"][-1].get("agent_output"): - last_output = task["steps"][-1]["agent_output"] - if "done" in last_output and "text" in last_output["done"]: - result_text += f"Result: {last_output['done']['text']}\n\n" - - return [ - types.TextContent( - type="text", - text=result_text, - ) - ] - - # Fallback for unexpected cases - return [ - types.TextContent( - type="text", - text=f"Task {task_id} completed with status '{task['status']}' but no results are available.", - ) - ] - - elif name == "mcp__browser_health": - try: - # Check browser health - logger.info("Health check requested") - healthy = await check_browser_health(context) - status = "healthy" if healthy else "unhealthy" - logger.info(f"Browser health status: {status}") - return [ - types.TextContent(type="text", text=f"Browser status: {status}") - ] - - except Exception as e: - logger.error(f"Error checking browser health: {str(e)}") - return [ - types.TextContent( - type="text", text=f"Error checking browser health: {str(e)}" - ) - ] - - elif name == "mcp__browser_reset": - try: - # Reset browser context - logger.info("Browser reset requested") - await reset_browser_context(context) - logger.info("Browser context reset successful") - return [ - types.TextContent( - type="text", text="Browser context reset successfully" - ) - ] - - except Exception as e: - logger.error(f"Error resetting browser context: {str(e)}") - return [ - types.TextContent( - type="text", text=f"Error resetting browser context: {str(e)}" - ) - ] - - else: - logger.warning(f"Unknown tool requested: {name}") - return [types.TextContent(type="text", text=f"Unknown tool: {name}")] - - @app.list_tools() - async def list_tools() -> list[types.Tool]: - """ - List available tools for the MCP client. - - Returns: - List of available tools - """ - try: - logger.info("list_tools called - preparing to return tools") - tools = [ - types.Tool( - name="mcp__browser_navigate", - description="Navigate to a URL and perform an action. This is a synchronous operation that will return when the task is complete.", - inputSchema={ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The URL to navigate to", - }, - "action": { - "type": "string", - "description": "The action to perform on the page", - }, - }, - "required": ["url"], - }, - ), - types.Tool( - name="mcp__browser_use", - description="Performs a browser action asynchronously and returns a task ID. Use mcp__browser_get_result with the returned task ID to check the status and get results.", - inputSchema={ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL to navigate to", - }, - "action": { - "type": "string", - "description": "Action to perform in the browser (e.g., 'Extract all headlines', 'Search for X')", - }, - }, - "required": ["url", "action"], - }, - ), - types.Tool( - name="mcp__browser_get_result", - description="Gets the result of an asynchronous browser task. Use this to check the status of a task started with mcp__browser_use.", - inputSchema={ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to get results for", - }, - }, - "required": ["task_id"], - }, - ), - types.Tool( - name="mcp__browser_health", - description="Check browser health status and attempt recovery if needed", - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - types.Tool( - name="mcp__browser_reset", - description="Force reset of the browser context to recover from errors", - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - ] - logger.info(f"Successfully prepared {len(tools)} tools") - return tools - except Exception as e: - logger.error(f"Error in list_tools: {str(e)}") - raise - - @app.list_resources() - async def list_resources() -> list[types.Resource]: - """ - List available resources for the MCP client. - - Returns: - List of available resources - """ - resources = [] - - # Add all completed tasks as resources - for task_id, task in store.items(): - if task["status"] in ["completed", "error"]: - resources.append( - types.Resource( - uri=f"browser-task://{task_id}", - title=f"Browser Task: {task['url']}", - description=f"Status: {task['status']}", - ) - ) - - return resources - - @app.read_resource() - async def read_resource(uri: str) -> list[types.ResourceContents]: - """ - Read resource content by URI. - - Args: - uri: Resource URI - - Returns: - Resource contents - """ - # Extract task ID from URI - if not uri.startswith("browser-task://"): - return [types.ResourceContents(error="Invalid resource URI format")] - - task_id = uri[15:] # Remove "browser-task://" prefix - - # Check if task exists - if task_id not in store: - return [types.ResourceContents(error=f"Task {task_id} not found")] - - task = store[task_id] - - # Check task status - if task["status"] == "error": - return [ - types.ResourceContents( - mimetype="text/plain", - contents=f"Error: {task['error']}", - ) - ] - - if task["status"] == "running": - # For running tasks, return the steps completed so far - steps_text = "\n".join( - [ - f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" - for s in task["steps"] - ] - ) - return [ - types.ResourceContents( - mimetype="text/plain", - contents=f"Task {task_id} is still running.\n\nSteps completed so far:\n{steps_text}", - ) - ] - - # For completed tasks, return the full result - if task["result"]: - # Format the result as markdown - result_text = "# Browser Task Report\n\n" - result_text += f"URL: {task['url']}\n\n" - result_text += f"Action: {task['action']}\n\n" - result_text += f"Start Time: {task['start_time']}\n\n" - result_text += f"End Time: {task['end_time']}\n\n" - - # Add steps - result_text += "## Steps\n\n" - for step in task["steps"]: - result_text += f"### Step {step['step']}\n\n" - result_text += f"Time: {step['timestamp']}\n\n" - - # Add agent output - if "agent_output" in step and step["agent_output"]: - result_text += "#### Agent Output\n\n" - action = step["agent_output"].get("action", "Unknown action") - result_text += f"Action: {action}\n\n" - - # Add agent thoughts if available - if "thought" in step["agent_output"]: - result_text += f"Thought: {step['agent_output']['thought']}\n\n" - - # Add browser state snapshot - if "browser_state" in step and step["browser_state"]: - result_text += "#### Browser State\n\n" - - # Add page title if available - if "page_title" in step["browser_state"]: - result_text += ( - f"Page Title: {step['browser_state']['page_title']}\n\n" - ) - - # Add URL if available - if "url" in step["browser_state"]: - result_text += f"URL: {step['browser_state']['url']}\n\n" - - # Add screenshot if available - if "screenshot" in step["browser_state"]: - result_text += ( - "Screenshot available but not included in text output.\n\n" - ) - - # Return formatted result - return [ - types.ResourceContents( - mimetype="text/markdown", - contents=result_text, - ) - ] - - # Fallback for unexpected cases - return [ - types.ResourceContents( - mimetype="text/plain", - contents=f"Task {task_id} completed with status '{task['status']}' but no results are available.", - ) - ] - - return app