ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""MCP (Model Context Protocol) support for browser-use.
|
||||
|
||||
This module provides integration with MCP servers and clients for browser automation.
|
||||
"""
|
||||
|
||||
from browser_use.mcp.client import MCPClient
|
||||
from browser_use.mcp.controller import MCPToolWrapper
|
||||
|
||||
__all__ = ['MCPClient', 'MCPToolWrapper', 'BrowserUseServer'] # type: ignore
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid importing server module when only client is needed."""
|
||||
if name == 'BrowserUseServer':
|
||||
from browser_use.mcp.server import BrowserUseServer
|
||||
|
||||
return BrowserUseServer
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Entry point for running MCP server as a module.
|
||||
|
||||
Usage:
|
||||
python -m browser_use.mcp
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_use.mcp.server import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,544 @@
|
||||
"""MCP (Model Context Protocol) client integration for browser-use.
|
||||
|
||||
This module provides integration between external MCP servers and browser-use's action registry.
|
||||
MCP tools are dynamically discovered and registered as browser-use actions.
|
||||
|
||||
Example usage:
|
||||
from browser_use import Tools
|
||||
from browser_use.mcp.client import MCPClient
|
||||
|
||||
tools = Tools()
|
||||
|
||||
# Connect to an MCP server
|
||||
mcp_client = MCPClient(
|
||||
server_name="my-server",
|
||||
command="npx",
|
||||
args=["@mycompany/mcp-server@latest"]
|
||||
)
|
||||
|
||||
# Register all MCP tools as browser-use actions
|
||||
await mcp_client.register_to_tools(tools)
|
||||
|
||||
# Now use with Agent as normal - MCP tools are available as actions
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, create_model
|
||||
|
||||
from browser_use.agent.views import ActionResult
|
||||
from browser_use.telemetry import MCPClientTelemetryEvent, ProductTelemetry
|
||||
from browser_use.tools.registry.service import Registry
|
||||
from browser_use.tools.service import Tools
|
||||
from browser_use.utils import get_browser_use_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import MCP SDK
|
||||
from mcp import ClientSession, StdioServerParameters, types
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
MCP_AVAILABLE = True
|
||||
|
||||
|
||||
class MCPClient:
|
||||
"""Client for connecting to MCP servers and exposing their tools as browser-use actions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_name: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
):
|
||||
"""Initialize MCP client.
|
||||
|
||||
Args:
|
||||
server_name: Name of the MCP server (for logging and identification)
|
||||
command: Command to start the MCP server (e.g., "npx", "python")
|
||||
args: Arguments for the command (e.g., ["@playwright/mcp@latest"])
|
||||
env: Environment variables for the server process
|
||||
"""
|
||||
self.server_name = server_name
|
||||
self.command = command
|
||||
self.args = args or []
|
||||
self.env = env
|
||||
|
||||
self.session: ClientSession | None = None
|
||||
self._stdio_task = None
|
||||
self._read_stream = None
|
||||
self._write_stream = None
|
||||
self._tools: dict[str, types.Tool] = {}
|
||||
self._registered_actions: set[str] = set()
|
||||
self._connected = False
|
||||
self._disconnect_event = asyncio.Event()
|
||||
self._telemetry = ProductTelemetry()
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the MCP server and discover available tools."""
|
||||
if self._connected:
|
||||
logger.debug(f'Already connected to {self.server_name}')
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
logger.info(f"🔌 Connecting to MCP server '{self.server_name}': {self.command} {' '.join(self.args)}")
|
||||
|
||||
# Create server parameters
|
||||
server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
|
||||
|
||||
# Start stdio client in background task
|
||||
self._stdio_task = asyncio.create_task(self._run_stdio_client(server_params))
|
||||
|
||||
# Wait for connection to be established
|
||||
retries = 0
|
||||
max_retries = 100 # 10 second timeout (increased for parallel test execution)
|
||||
while not self._connected and retries < max_retries:
|
||||
await asyncio.sleep(0.1)
|
||||
retries += 1
|
||||
|
||||
if not self._connected:
|
||||
error_msg = f"Failed to connect to MCP server '{self.server_name}' after {max_retries * 0.1} seconds"
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
logger.info(f"📦 Discovered {len(self._tools)} tools from '{self.server_name}': {list(self._tools.keys())}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
raise
|
||||
finally:
|
||||
# Capture telemetry for connect action
|
||||
duration = time.time() - start_time
|
||||
self._telemetry.capture(
|
||||
MCPClientTelemetryEvent(
|
||||
server_name=self.server_name,
|
||||
command=self.command,
|
||||
tools_discovered=len(self._tools),
|
||||
version=get_browser_use_version(),
|
||||
action='connect',
|
||||
duration_seconds=duration,
|
||||
error_message=error_msg,
|
||||
)
|
||||
)
|
||||
|
||||
async def _run_stdio_client(self, server_params: StdioServerParameters):
|
||||
"""Run the stdio client connection in a background task."""
|
||||
try:
|
||||
async with stdio_client(server_params) as (read_stream, write_stream):
|
||||
self._read_stream = read_stream
|
||||
self._write_stream = write_stream
|
||||
|
||||
# Create and initialize session
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
self.session = session
|
||||
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
|
||||
# Discover available tools
|
||||
tools_response = await session.list_tools()
|
||||
self._tools = {tool.name: tool for tool in tools_response.tools}
|
||||
|
||||
# Mark as connected
|
||||
self._connected = True
|
||||
|
||||
# Keep the connection alive until disconnect is called
|
||||
await self._disconnect_event.wait()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'MCP server connection error: {e}')
|
||||
self._connected = False
|
||||
raise
|
||||
finally:
|
||||
self._connected = False
|
||||
self.session = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the MCP server."""
|
||||
if not self._connected:
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
logger.info(f"🔌 Disconnecting from MCP server '{self.server_name}'")
|
||||
|
||||
# Signal disconnect
|
||||
self._connected = False
|
||||
self._disconnect_event.set()
|
||||
|
||||
# Wait for stdio task to finish
|
||||
if self._stdio_task:
|
||||
try:
|
||||
await asyncio.wait_for(self._stdio_task, timeout=2.0)
|
||||
except TimeoutError:
|
||||
logger.warning(f"Timeout waiting for MCP server '{self.server_name}' to disconnect")
|
||||
self._stdio_task.cancel()
|
||||
try:
|
||||
await self._stdio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._tools.clear()
|
||||
self._registered_actions.clear()
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f'Error disconnecting from MCP server: {e}')
|
||||
finally:
|
||||
# Capture telemetry for disconnect action
|
||||
duration = time.time() - start_time
|
||||
self._telemetry.capture(
|
||||
MCPClientTelemetryEvent(
|
||||
server_name=self.server_name,
|
||||
command=self.command,
|
||||
tools_discovered=0, # Tools cleared on disconnect
|
||||
version=get_browser_use_version(),
|
||||
action='disconnect',
|
||||
duration_seconds=duration,
|
||||
error_message=error_msg,
|
||||
)
|
||||
)
|
||||
self._telemetry.flush()
|
||||
|
||||
async def register_to_tools(
|
||||
self,
|
||||
tools: Tools,
|
||||
tool_filter: list[str] | None = None,
|
||||
prefix: str | None = None,
|
||||
) -> None:
|
||||
"""Register MCP tools as actions in the browser-use tools.
|
||||
|
||||
Args:
|
||||
tools: Browser-use tools to register actions to
|
||||
tool_filter: Optional list of tool names to register (None = all tools)
|
||||
prefix: Optional prefix to add to action names (e.g., "playwright_")
|
||||
"""
|
||||
if not self._connected:
|
||||
await self.connect()
|
||||
|
||||
registry = tools.registry
|
||||
|
||||
for tool_name, tool in self._tools.items():
|
||||
# Skip if not in filter
|
||||
if tool_filter and tool_name not in tool_filter:
|
||||
continue
|
||||
|
||||
# Apply prefix if specified
|
||||
action_name = f'{prefix}{tool_name}' if prefix else tool_name
|
||||
|
||||
# Skip if already registered
|
||||
if action_name in self._registered_actions:
|
||||
continue
|
||||
|
||||
# Register the tool as an action
|
||||
self._register_tool_as_action(registry, action_name, tool)
|
||||
self._registered_actions.add(action_name)
|
||||
|
||||
logger.info(f"✅ Registered {len(self._registered_actions)} MCP tools from '{self.server_name}' as browser-use actions")
|
||||
|
||||
def _register_tool_as_action(self, registry: Registry, action_name: str, tool: Any) -> None:
|
||||
"""Register a single MCP tool as a browser-use action.
|
||||
|
||||
Args:
|
||||
registry: Browser-use registry to register action to
|
||||
action_name: Name for the registered action
|
||||
tool: MCP Tool object with schema information
|
||||
"""
|
||||
# Parse tool parameters to create Pydantic model
|
||||
param_fields = {}
|
||||
|
||||
if tool.inputSchema:
|
||||
# MCP tools use JSON Schema for parameters
|
||||
properties = tool.inputSchema.get('properties', {})
|
||||
required = set(tool.inputSchema.get('required', []))
|
||||
|
||||
for param_name, param_schema in properties.items():
|
||||
# Convert JSON Schema type to Python type
|
||||
param_type = self._json_schema_to_python_type(param_schema, f'{action_name}_{param_name}')
|
||||
|
||||
# Determine if field is required and handle defaults
|
||||
if param_name in required:
|
||||
default = ... # Required field
|
||||
else:
|
||||
# Optional field - make type optional and handle default
|
||||
param_type = param_type | None
|
||||
if 'default' in param_schema:
|
||||
default = param_schema['default']
|
||||
else:
|
||||
default = None
|
||||
|
||||
# Add field with description if available
|
||||
field_kwargs = {}
|
||||
if 'description' in param_schema:
|
||||
field_kwargs['description'] = param_schema['description']
|
||||
|
||||
param_fields[param_name] = (param_type, Field(default, **field_kwargs))
|
||||
|
||||
# Create Pydantic model for the tool parameters
|
||||
if param_fields:
|
||||
# Create a BaseModel class with proper configuration
|
||||
class ConfiguredBaseModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', validate_by_name=True, validate_by_alias=True)
|
||||
|
||||
param_model = create_model(f'{action_name}_Params', __base__=ConfiguredBaseModel, **param_fields)
|
||||
else:
|
||||
# No parameters - create empty model
|
||||
param_model = None
|
||||
|
||||
# Determine if this is a browser-specific tool
|
||||
is_browser_tool = tool.name.startswith('browser_') or 'page' in tool.name.lower()
|
||||
|
||||
# Set up action filters
|
||||
domains = None
|
||||
# Note: page_filter has been removed since we no longer use Page objects
|
||||
# Browser tools filtering would need to be done via domain filters instead
|
||||
|
||||
# Create async wrapper function for the MCP tool
|
||||
# Need to define function with explicit parameters to satisfy registry validation
|
||||
if param_model:
|
||||
# Type 1: Function takes param model as first parameter
|
||||
async def mcp_action_wrapper(params: param_model) -> ActionResult: # type: ignore[no-redef]
|
||||
"""Wrapper function that calls the MCP tool."""
|
||||
if not self.session or not self._connected:
|
||||
return ActionResult(error=f"MCP server '{self.server_name}' not connected", success=False)
|
||||
|
||||
# Convert pydantic model to dict for MCP call
|
||||
tool_params = params.model_dump(exclude_none=True)
|
||||
|
||||
logger.debug(f"🔧 Calling MCP tool '{tool.name}' with params: {tool_params}")
|
||||
|
||||
start_time = time.time()
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
# Call the MCP tool
|
||||
result = await self.session.call_tool(tool.name, tool_params)
|
||||
|
||||
# Convert MCP result to ActionResult
|
||||
extracted_content = self._format_mcp_result(result)
|
||||
|
||||
return ActionResult(
|
||||
extracted_content=extracted_content,
|
||||
long_term_memory=f"Used MCP tool '{tool.name}' from {self.server_name}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"MCP tool '{tool.name}' failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return ActionResult(error=error_msg, success=False)
|
||||
finally:
|
||||
# Capture telemetry for tool call
|
||||
duration = time.time() - start_time
|
||||
self._telemetry.capture(
|
||||
MCPClientTelemetryEvent(
|
||||
server_name=self.server_name,
|
||||
command=self.command,
|
||||
tools_discovered=len(self._tools),
|
||||
version=get_browser_use_version(),
|
||||
action='tool_call',
|
||||
tool_name=tool.name,
|
||||
duration_seconds=duration,
|
||||
error_message=error_msg,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No parameters - empty function signature
|
||||
async def mcp_action_wrapper() -> ActionResult: # type: ignore[no-redef]
|
||||
"""Wrapper function that calls the MCP tool."""
|
||||
if not self.session or not self._connected:
|
||||
return ActionResult(error=f"MCP server '{self.server_name}' not connected", success=False)
|
||||
|
||||
logger.debug(f"🔧 Calling MCP tool '{tool.name}' with no params")
|
||||
|
||||
start_time = time.time()
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
# Call the MCP tool with empty params
|
||||
result = await self.session.call_tool(tool.name, {})
|
||||
|
||||
# Convert MCP result to ActionResult
|
||||
extracted_content = self._format_mcp_result(result)
|
||||
|
||||
return ActionResult(
|
||||
extracted_content=extracted_content,
|
||||
long_term_memory=f"Used MCP tool '{tool.name}' from {self.server_name}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"MCP tool '{tool.name}' failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return ActionResult(error=error_msg, success=False)
|
||||
finally:
|
||||
# Capture telemetry for tool call
|
||||
duration = time.time() - start_time
|
||||
self._telemetry.capture(
|
||||
MCPClientTelemetryEvent(
|
||||
server_name=self.server_name,
|
||||
command=self.command,
|
||||
tools_discovered=len(self._tools),
|
||||
version=get_browser_use_version(),
|
||||
action='tool_call',
|
||||
tool_name=tool.name,
|
||||
duration_seconds=duration,
|
||||
error_message=error_msg,
|
||||
)
|
||||
)
|
||||
|
||||
# Set function metadata for better debugging
|
||||
mcp_action_wrapper.__name__ = action_name
|
||||
mcp_action_wrapper.__qualname__ = f'mcp.{self.server_name}.{action_name}'
|
||||
|
||||
# Register the action with browser-use
|
||||
description = tool.description or f'MCP tool from {self.server_name}: {tool.name}'
|
||||
|
||||
# Use the registry's action decorator
|
||||
registry.action(description=description, param_model=param_model, domains=domains)(mcp_action_wrapper)
|
||||
|
||||
logger.debug(f"✅ Registered MCP tool '{tool.name}' as action '{action_name}'")
|
||||
|
||||
def _format_mcp_result(self, result: Any) -> str:
|
||||
"""Format MCP tool result into a string for ActionResult.
|
||||
|
||||
Args:
|
||||
result: Raw result from MCP tool call
|
||||
|
||||
Returns:
|
||||
Formatted string representation of the result
|
||||
"""
|
||||
# Handle different MCP result formats
|
||||
if hasattr(result, 'content'):
|
||||
# Structured content response
|
||||
if isinstance(result.content, list):
|
||||
# Multiple content items
|
||||
parts = []
|
||||
for item in result.content:
|
||||
if hasattr(item, 'text'):
|
||||
parts.append(item.text)
|
||||
elif hasattr(item, 'type') and item.type == 'text':
|
||||
parts.append(str(item))
|
||||
else:
|
||||
parts.append(str(item))
|
||||
return '\n'.join(parts)
|
||||
else:
|
||||
return str(result.content)
|
||||
elif isinstance(result, list):
|
||||
# List of content items
|
||||
parts = []
|
||||
for item in result:
|
||||
if hasattr(item, 'text'):
|
||||
parts.append(item.text)
|
||||
else:
|
||||
parts.append(str(item))
|
||||
return '\n'.join(parts)
|
||||
else:
|
||||
# Direct result or unknown format
|
||||
return str(result)
|
||||
|
||||
def _json_schema_to_python_type(self, schema: dict, model_name: str = 'NestedModel') -> Any:
|
||||
"""Convert JSON Schema type to Python type.
|
||||
|
||||
Args:
|
||||
schema: JSON Schema definition
|
||||
model_name: Name for nested models
|
||||
|
||||
Returns:
|
||||
Python type corresponding to the schema
|
||||
"""
|
||||
json_type = schema.get('type', 'string')
|
||||
|
||||
# Basic type mapping
|
||||
type_mapping = {
|
||||
'string': str,
|
||||
'number': float,
|
||||
'integer': int,
|
||||
'boolean': bool,
|
||||
'array': list,
|
||||
'null': type(None),
|
||||
}
|
||||
|
||||
# Handle enums (they're still strings)
|
||||
if 'enum' in schema:
|
||||
return str
|
||||
|
||||
# Handle objects with nested properties
|
||||
if json_type == 'object':
|
||||
properties = schema.get('properties', {})
|
||||
if properties:
|
||||
# Create nested pydantic model for objects with properties
|
||||
nested_fields = {}
|
||||
required_fields = set(schema.get('required', []))
|
||||
|
||||
for prop_name, prop_schema in properties.items():
|
||||
# Recursively process nested properties
|
||||
prop_type = self._json_schema_to_python_type(prop_schema, f'{model_name}_{prop_name}')
|
||||
|
||||
# Determine if field is required and handle defaults
|
||||
if prop_name in required_fields:
|
||||
default = ... # Required field
|
||||
else:
|
||||
# Optional field - make type optional and handle default
|
||||
prop_type = prop_type | None
|
||||
if 'default' in prop_schema:
|
||||
default = prop_schema['default']
|
||||
else:
|
||||
default = None
|
||||
|
||||
# Add field with description if available
|
||||
field_kwargs = {}
|
||||
if 'description' in prop_schema:
|
||||
field_kwargs['description'] = prop_schema['description']
|
||||
|
||||
nested_fields[prop_name] = (prop_type, Field(default, **field_kwargs))
|
||||
|
||||
# Create a BaseModel class with proper configuration
|
||||
class ConfiguredBaseModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', validate_by_name=True, validate_by_alias=True)
|
||||
|
||||
try:
|
||||
# Create and return nested pydantic model
|
||||
return create_model(model_name, __base__=ConfiguredBaseModel, **nested_fields)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to create nested model {model_name}: {e}')
|
||||
logger.debug(f'Fields: {nested_fields}')
|
||||
# Fallback to basic dict if model creation fails
|
||||
return dict
|
||||
else:
|
||||
# Object without properties - just return dict
|
||||
return dict
|
||||
|
||||
# Handle arrays with specific item types
|
||||
if json_type == 'array':
|
||||
if 'items' in schema:
|
||||
# Get the item type recursively
|
||||
item_type = self._json_schema_to_python_type(schema['items'], f'{model_name}_item')
|
||||
# Return properly typed list
|
||||
return list[item_type]
|
||||
else:
|
||||
# Array without item type specification
|
||||
return list
|
||||
|
||||
# Get base type for non-object types
|
||||
base_type = type_mapping.get(json_type, str)
|
||||
|
||||
# Handle nullable/optional types
|
||||
if schema.get('nullable', False) or json_type == 'null':
|
||||
return base_type | None
|
||||
|
||||
return base_type
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.disconnect()
|
||||
@@ -0,0 +1,264 @@
|
||||
"""MCP (Model Context Protocol) tool wrapper for browser-use.
|
||||
|
||||
This module provides integration between MCP tools and browser-use's action registry system.
|
||||
MCP tools are dynamically discovered and registered as browser-use actions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, create_model
|
||||
|
||||
from browser_use.agent.views import ActionResult
|
||||
from browser_use.tools.registry.service import Registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.types import TextContent, Tool
|
||||
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
MCP_AVAILABLE = False
|
||||
logger.warning('MCP SDK not installed. Install with: pip install mcp')
|
||||
|
||||
|
||||
class MCPToolWrapper:
|
||||
"""Wrapper to integrate MCP tools as browser-use actions."""
|
||||
|
||||
def __init__(self, registry: Registry, mcp_command: str, mcp_args: list[str] | None = None):
|
||||
"""Initialize MCP tool wrapper.
|
||||
|
||||
Args:
|
||||
registry: Browser-use action registry to register MCP tools
|
||||
mcp_command: Command to start MCP server (e.g., "npx")
|
||||
mcp_args: Arguments for MCP command (e.g., ["@playwright/mcp@latest"])
|
||||
"""
|
||||
if not MCP_AVAILABLE:
|
||||
raise ImportError('MCP SDK not installed. Install with: pip install mcp')
|
||||
|
||||
self.registry = registry
|
||||
self.mcp_command = mcp_command
|
||||
self.mcp_args = mcp_args or []
|
||||
self.session: ClientSession | None = None
|
||||
self._tools: dict[str, Tool] = {}
|
||||
self._registered_actions: set[str] = set()
|
||||
self._shutdown_event = asyncio.Event()
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to MCP server and discover available tools."""
|
||||
if self.session:
|
||||
return # Already connected
|
||||
|
||||
logger.info(f'🔌 Connecting to MCP server: {self.mcp_command} {" ".join(self.mcp_args)}')
|
||||
|
||||
# Create server parameters
|
||||
server_params = StdioServerParameters(command=self.mcp_command, args=self.mcp_args, env=None)
|
||||
|
||||
# Connect to the MCP server
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
self.session = session
|
||||
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
|
||||
# Discover available tools
|
||||
tools_response = await session.list_tools()
|
||||
self._tools = {tool.name: tool for tool in tools_response.tools}
|
||||
|
||||
logger.info(f'📦 Discovered {len(self._tools)} MCP tools: {list(self._tools.keys())}')
|
||||
|
||||
# Register all discovered tools as actions
|
||||
for tool_name, tool in self._tools.items():
|
||||
self._register_tool_as_action(tool_name, tool)
|
||||
|
||||
# Keep session alive while tools are being used
|
||||
await self._keep_session_alive()
|
||||
|
||||
async def _keep_session_alive(self):
|
||||
"""Keep the MCP session alive."""
|
||||
# This will block until the session is closed
|
||||
# In practice, you'd want to manage this lifecycle better
|
||||
try:
|
||||
await self._shutdown_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _register_tool_as_action(self, tool_name: str, tool: Tool):
|
||||
"""Register an MCP tool as a browser-use action.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the MCP tool
|
||||
tool: MCP Tool object with schema information
|
||||
"""
|
||||
if tool_name in self._registered_actions:
|
||||
return # Already registered
|
||||
|
||||
# Parse tool parameters to create Pydantic model
|
||||
param_fields = {}
|
||||
|
||||
if tool.inputSchema:
|
||||
# MCP tools use JSON Schema for parameters
|
||||
properties = tool.inputSchema.get('properties', {})
|
||||
required = set(tool.inputSchema.get('required', []))
|
||||
|
||||
for param_name, param_schema in properties.items():
|
||||
# Convert JSON Schema type to Python type
|
||||
param_type = self._json_schema_to_python_type(param_schema)
|
||||
|
||||
# Determine if field is required
|
||||
if param_name in required:
|
||||
default = ... # Required field
|
||||
else:
|
||||
default = param_schema.get('default', None)
|
||||
|
||||
# Add field description if available
|
||||
field_kwargs = {}
|
||||
if 'description' in param_schema:
|
||||
field_kwargs['description'] = param_schema['description']
|
||||
|
||||
param_fields[param_name] = (param_type, Field(default, **field_kwargs))
|
||||
|
||||
# Create Pydantic model for the tool parameters
|
||||
param_model = create_model(f'{tool_name}_Params', **param_fields) if param_fields else None
|
||||
|
||||
# Determine if this is a browser-specific tool
|
||||
is_browser_tool = tool_name.startswith('browser_')
|
||||
domains = None
|
||||
# Note: page_filter has been removed since we no longer use Page objects
|
||||
|
||||
# Create wrapper function for the MCP tool
|
||||
async def mcp_action_wrapper(**kwargs):
|
||||
"""Wrapper function that calls the MCP tool."""
|
||||
if not self.session:
|
||||
raise RuntimeError(f'MCP session not connected for tool {tool_name}')
|
||||
|
||||
# Extract parameters (excluding special injected params)
|
||||
special_params = {
|
||||
'page',
|
||||
'browser_session',
|
||||
'context',
|
||||
'page_extraction_llm',
|
||||
'file_system',
|
||||
'available_file_paths',
|
||||
'has_sensitive_data',
|
||||
'browser',
|
||||
'browser_context',
|
||||
}
|
||||
|
||||
tool_params = {k: v for k, v in kwargs.items() if k not in special_params}
|
||||
|
||||
logger.debug(f'🔧 Calling MCP tool {tool_name} with params: {tool_params}')
|
||||
|
||||
try:
|
||||
# Call the MCP tool
|
||||
result = await self.session.call_tool(tool_name, tool_params)
|
||||
|
||||
# Convert MCP result to ActionResult
|
||||
# MCP tools return results in various formats
|
||||
if hasattr(result, 'content'):
|
||||
# Handle structured content responses
|
||||
if isinstance(result.content, list):
|
||||
# Multiple content items
|
||||
content_parts = []
|
||||
for item in result.content:
|
||||
if isinstance(item, TextContent):
|
||||
content_parts.append(item.text) # type: ignore[reportAttributeAccessIssue]
|
||||
else:
|
||||
content_parts.append(str(item))
|
||||
extracted_content = '\n'.join(content_parts)
|
||||
else:
|
||||
extracted_content = str(result.content)
|
||||
else:
|
||||
# Direct result
|
||||
extracted_content = str(result)
|
||||
|
||||
return ActionResult(extracted_content=extracted_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ MCP tool {tool_name} failed: {e}')
|
||||
return ActionResult(extracted_content=f'MCP tool {tool_name} failed: {str(e)}', error=str(e))
|
||||
|
||||
# Set function name for better debugging
|
||||
mcp_action_wrapper.__name__ = tool_name
|
||||
mcp_action_wrapper.__qualname__ = f'mcp.{tool_name}'
|
||||
|
||||
# Register the action with browser-use
|
||||
description = tool.description or f'MCP tool: {tool_name}'
|
||||
|
||||
# Use the decorator to register the action
|
||||
decorated_wrapper = self.registry.action(description=description, param_model=param_model, domains=domains)(
|
||||
mcp_action_wrapper
|
||||
)
|
||||
|
||||
self._registered_actions.add(tool_name)
|
||||
logger.info(f'✅ Registered MCP tool as action: {tool_name}')
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the MCP server and clean up resources."""
|
||||
self._shutdown_event.set()
|
||||
if self.session:
|
||||
# Session cleanup will be handled by the context manager
|
||||
self.session = None
|
||||
|
||||
def _json_schema_to_python_type(self, schema: dict) -> Any:
|
||||
"""Convert JSON Schema type to Python type.
|
||||
|
||||
Args:
|
||||
schema: JSON Schema definition
|
||||
|
||||
Returns:
|
||||
Python type corresponding to the schema
|
||||
"""
|
||||
json_type = schema.get('type', 'string')
|
||||
|
||||
type_mapping = {
|
||||
'string': str,
|
||||
'number': float,
|
||||
'integer': int,
|
||||
'boolean': bool,
|
||||
'array': list,
|
||||
'object': dict,
|
||||
}
|
||||
|
||||
base_type = type_mapping.get(json_type, str)
|
||||
|
||||
# Handle nullable types
|
||||
if schema.get('nullable', False):
|
||||
return base_type | None
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
# Convenience function for easy integration
|
||||
async def register_mcp_tools(registry: Registry, mcp_command: str, mcp_args: list[str] | None = None) -> MCPToolWrapper:
|
||||
"""Register MCP tools with a browser-use registry.
|
||||
|
||||
Args:
|
||||
registry: Browser-use action registry
|
||||
mcp_command: Command to start MCP server
|
||||
mcp_args: Arguments for MCP command
|
||||
|
||||
Returns:
|
||||
MCPToolWrapper instance (connected)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from browser_use import Tools
|
||||
from browser_use.mcp.tools import register_mcp_tools
|
||||
|
||||
tools = Tools()
|
||||
|
||||
# Register Playwright MCP tools
|
||||
mcp = await register_mcp_tools(tools.registry, 'npx', ['@playwright/mcp@latest', '--headless'])
|
||||
|
||||
# Now all MCP tools are available as browser-use actions
|
||||
```
|
||||
"""
|
||||
wrapper = MCPToolWrapper(registry, mcp_command, mcp_args)
|
||||
await wrapper.connect()
|
||||
return wrapper
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user