optexity-browser-use 0.9.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_use/__init__.py +157 -0
- browser_use/actor/__init__.py +11 -0
- browser_use/actor/element.py +1175 -0
- browser_use/actor/mouse.py +134 -0
- browser_use/actor/page.py +561 -0
- browser_use/actor/playground/flights.py +41 -0
- browser_use/actor/playground/mixed_automation.py +54 -0
- browser_use/actor/playground/playground.py +236 -0
- browser_use/actor/utils.py +176 -0
- browser_use/agent/cloud_events.py +282 -0
- browser_use/agent/gif.py +424 -0
- browser_use/agent/judge.py +170 -0
- browser_use/agent/message_manager/service.py +473 -0
- browser_use/agent/message_manager/utils.py +52 -0
- browser_use/agent/message_manager/views.py +98 -0
- browser_use/agent/prompts.py +413 -0
- browser_use/agent/service.py +2316 -0
- browser_use/agent/system_prompt.md +185 -0
- browser_use/agent/system_prompt_flash.md +10 -0
- browser_use/agent/system_prompt_no_thinking.md +183 -0
- browser_use/agent/views.py +743 -0
- browser_use/browser/__init__.py +41 -0
- browser_use/browser/cloud/cloud.py +203 -0
- browser_use/browser/cloud/views.py +89 -0
- browser_use/browser/events.py +578 -0
- browser_use/browser/profile.py +1158 -0
- browser_use/browser/python_highlights.py +548 -0
- browser_use/browser/session.py +3225 -0
- browser_use/browser/session_manager.py +399 -0
- browser_use/browser/video_recorder.py +162 -0
- browser_use/browser/views.py +200 -0
- browser_use/browser/watchdog_base.py +260 -0
- browser_use/browser/watchdogs/__init__.py +0 -0
- browser_use/browser/watchdogs/aboutblank_watchdog.py +253 -0
- browser_use/browser/watchdogs/crash_watchdog.py +335 -0
- browser_use/browser/watchdogs/default_action_watchdog.py +2729 -0
- browser_use/browser/watchdogs/dom_watchdog.py +817 -0
- browser_use/browser/watchdogs/downloads_watchdog.py +1277 -0
- browser_use/browser/watchdogs/local_browser_watchdog.py +461 -0
- browser_use/browser/watchdogs/permissions_watchdog.py +43 -0
- browser_use/browser/watchdogs/popups_watchdog.py +143 -0
- browser_use/browser/watchdogs/recording_watchdog.py +126 -0
- browser_use/browser/watchdogs/screenshot_watchdog.py +62 -0
- browser_use/browser/watchdogs/security_watchdog.py +280 -0
- browser_use/browser/watchdogs/storage_state_watchdog.py +335 -0
- browser_use/cli.py +2359 -0
- browser_use/code_use/__init__.py +16 -0
- browser_use/code_use/formatting.py +192 -0
- browser_use/code_use/namespace.py +665 -0
- browser_use/code_use/notebook_export.py +276 -0
- browser_use/code_use/service.py +1340 -0
- browser_use/code_use/system_prompt.md +574 -0
- browser_use/code_use/utils.py +150 -0
- browser_use/code_use/views.py +171 -0
- browser_use/config.py +505 -0
- browser_use/controller/__init__.py +3 -0
- browser_use/dom/enhanced_snapshot.py +161 -0
- browser_use/dom/markdown_extractor.py +169 -0
- browser_use/dom/playground/extraction.py +312 -0
- browser_use/dom/playground/multi_act.py +32 -0
- browser_use/dom/serializer/clickable_elements.py +200 -0
- browser_use/dom/serializer/code_use_serializer.py +287 -0
- browser_use/dom/serializer/eval_serializer.py +478 -0
- browser_use/dom/serializer/html_serializer.py +212 -0
- browser_use/dom/serializer/paint_order.py +197 -0
- browser_use/dom/serializer/serializer.py +1170 -0
- browser_use/dom/service.py +825 -0
- browser_use/dom/utils.py +129 -0
- browser_use/dom/views.py +906 -0
- browser_use/exceptions.py +5 -0
- browser_use/filesystem/__init__.py +0 -0
- browser_use/filesystem/file_system.py +619 -0
- browser_use/init_cmd.py +376 -0
- browser_use/integrations/gmail/__init__.py +24 -0
- browser_use/integrations/gmail/actions.py +115 -0
- browser_use/integrations/gmail/service.py +225 -0
- browser_use/llm/__init__.py +155 -0
- browser_use/llm/anthropic/chat.py +242 -0
- browser_use/llm/anthropic/serializer.py +312 -0
- browser_use/llm/aws/__init__.py +36 -0
- browser_use/llm/aws/chat_anthropic.py +242 -0
- browser_use/llm/aws/chat_bedrock.py +289 -0
- browser_use/llm/aws/serializer.py +257 -0
- browser_use/llm/azure/chat.py +91 -0
- browser_use/llm/base.py +57 -0
- browser_use/llm/browser_use/__init__.py +3 -0
- browser_use/llm/browser_use/chat.py +201 -0
- browser_use/llm/cerebras/chat.py +193 -0
- browser_use/llm/cerebras/serializer.py +109 -0
- browser_use/llm/deepseek/chat.py +212 -0
- browser_use/llm/deepseek/serializer.py +109 -0
- browser_use/llm/exceptions.py +29 -0
- browser_use/llm/google/__init__.py +3 -0
- browser_use/llm/google/chat.py +542 -0
- browser_use/llm/google/serializer.py +120 -0
- browser_use/llm/groq/chat.py +229 -0
- browser_use/llm/groq/parser.py +158 -0
- browser_use/llm/groq/serializer.py +159 -0
- browser_use/llm/messages.py +238 -0
- browser_use/llm/models.py +271 -0
- browser_use/llm/oci_raw/__init__.py +10 -0
- browser_use/llm/oci_raw/chat.py +443 -0
- browser_use/llm/oci_raw/serializer.py +229 -0
- browser_use/llm/ollama/chat.py +97 -0
- browser_use/llm/ollama/serializer.py +143 -0
- browser_use/llm/openai/chat.py +264 -0
- browser_use/llm/openai/like.py +15 -0
- browser_use/llm/openai/serializer.py +165 -0
- browser_use/llm/openrouter/chat.py +211 -0
- browser_use/llm/openrouter/serializer.py +26 -0
- browser_use/llm/schema.py +176 -0
- browser_use/llm/views.py +48 -0
- browser_use/logging_config.py +330 -0
- browser_use/mcp/__init__.py +18 -0
- browser_use/mcp/__main__.py +12 -0
- browser_use/mcp/client.py +544 -0
- browser_use/mcp/controller.py +264 -0
- browser_use/mcp/server.py +1114 -0
- browser_use/observability.py +204 -0
- browser_use/py.typed +0 -0
- browser_use/sandbox/__init__.py +41 -0
- browser_use/sandbox/sandbox.py +637 -0
- browser_use/sandbox/views.py +132 -0
- browser_use/screenshots/__init__.py +1 -0
- browser_use/screenshots/service.py +52 -0
- browser_use/sync/__init__.py +6 -0
- browser_use/sync/auth.py +357 -0
- browser_use/sync/service.py +161 -0
- browser_use/telemetry/__init__.py +51 -0
- browser_use/telemetry/service.py +112 -0
- browser_use/telemetry/views.py +101 -0
- browser_use/tokens/__init__.py +0 -0
- browser_use/tokens/custom_pricing.py +24 -0
- browser_use/tokens/mappings.py +4 -0
- browser_use/tokens/service.py +580 -0
- browser_use/tokens/views.py +108 -0
- browser_use/tools/registry/service.py +572 -0
- browser_use/tools/registry/views.py +174 -0
- browser_use/tools/service.py +1675 -0
- browser_use/tools/utils.py +82 -0
- browser_use/tools/views.py +100 -0
- browser_use/utils.py +670 -0
- optexity_browser_use-0.9.5.dist-info/METADATA +344 -0
- optexity_browser_use-0.9.5.dist-info/RECORD +147 -0
- optexity_browser_use-0.9.5.dist-info/WHEEL +4 -0
- optexity_browser_use-0.9.5.dist-info/entry_points.txt +3 -0
- optexity_browser_use-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) client integration for browser-use.
|
|
2
|
+
|
|
3
|
+
This module provides integration between external MCP servers and browser-use's action registry.
|
|
4
|
+
MCP tools are dynamically discovered and registered as browser-use actions.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from browser_use import Tools
|
|
8
|
+
from browser_use.mcp.client import MCPClient
|
|
9
|
+
|
|
10
|
+
tools = Tools()
|
|
11
|
+
|
|
12
|
+
# Connect to an MCP server
|
|
13
|
+
mcp_client = MCPClient(
|
|
14
|
+
server_name="my-server",
|
|
15
|
+
command="npx",
|
|
16
|
+
args=["@mycompany/mcp-server@latest"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Register all MCP tools as browser-use actions
|
|
20
|
+
await mcp_client.register_to_tools(tools)
|
|
21
|
+
|
|
22
|
+
# Now use with Agent as normal - MCP tools are available as actions
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
31
|
+
|
|
32
|
+
from browser_use.agent.views import ActionResult
|
|
33
|
+
from browser_use.telemetry import MCPClientTelemetryEvent, ProductTelemetry
|
|
34
|
+
from browser_use.tools.registry.service import Registry
|
|
35
|
+
from browser_use.tools.service import Tools
|
|
36
|
+
from browser_use.utils import get_browser_use_version
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Import MCP SDK
|
|
41
|
+
from mcp import ClientSession, StdioServerParameters, types
|
|
42
|
+
from mcp.client.stdio import stdio_client
|
|
43
|
+
|
|
44
|
+
MCP_AVAILABLE = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MCPClient:
|
|
48
|
+
"""Client for connecting to MCP servers and exposing their tools as browser-use actions."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
server_name: str,
|
|
53
|
+
command: str,
|
|
54
|
+
args: list[str] | None = None,
|
|
55
|
+
env: dict[str, str] | None = None,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize MCP client.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
server_name: Name of the MCP server (for logging and identification)
|
|
61
|
+
command: Command to start the MCP server (e.g., "npx", "python")
|
|
62
|
+
args: Arguments for the command (e.g., ["@playwright/mcp@latest"])
|
|
63
|
+
env: Environment variables for the server process
|
|
64
|
+
"""
|
|
65
|
+
self.server_name = server_name
|
|
66
|
+
self.command = command
|
|
67
|
+
self.args = args or []
|
|
68
|
+
self.env = env
|
|
69
|
+
|
|
70
|
+
self.session: ClientSession | None = None
|
|
71
|
+
self._stdio_task = None
|
|
72
|
+
self._read_stream = None
|
|
73
|
+
self._write_stream = None
|
|
74
|
+
self._tools: dict[str, types.Tool] = {}
|
|
75
|
+
self._registered_actions: set[str] = set()
|
|
76
|
+
self._connected = False
|
|
77
|
+
self._disconnect_event = asyncio.Event()
|
|
78
|
+
self._telemetry = ProductTelemetry()
|
|
79
|
+
|
|
80
|
+
async def connect(self) -> None:
|
|
81
|
+
"""Connect to the MCP server and discover available tools."""
|
|
82
|
+
if self._connected:
|
|
83
|
+
logger.debug(f'Already connected to {self.server_name}')
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
start_time = time.time()
|
|
87
|
+
error_msg = None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
logger.info(f"🔌 Connecting to MCP server '{self.server_name}': {self.command} {' '.join(self.args)}")
|
|
91
|
+
|
|
92
|
+
# Create server parameters
|
|
93
|
+
server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
|
|
94
|
+
|
|
95
|
+
# Start stdio client in background task
|
|
96
|
+
self._stdio_task = asyncio.create_task(self._run_stdio_client(server_params))
|
|
97
|
+
|
|
98
|
+
# Wait for connection to be established
|
|
99
|
+
retries = 0
|
|
100
|
+
max_retries = 100 # 10 second timeout (increased for parallel test execution)
|
|
101
|
+
while not self._connected and retries < max_retries:
|
|
102
|
+
await asyncio.sleep(0.1)
|
|
103
|
+
retries += 1
|
|
104
|
+
|
|
105
|
+
if not self._connected:
|
|
106
|
+
error_msg = f"Failed to connect to MCP server '{self.server_name}' after {max_retries * 0.1} seconds"
|
|
107
|
+
raise RuntimeError(error_msg)
|
|
108
|
+
|
|
109
|
+
logger.info(f"📦 Discovered {len(self._tools)} tools from '{self.server_name}': {list(self._tools.keys())}")
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
error_msg = str(e)
|
|
113
|
+
raise
|
|
114
|
+
finally:
|
|
115
|
+
# Capture telemetry for connect action
|
|
116
|
+
duration = time.time() - start_time
|
|
117
|
+
self._telemetry.capture(
|
|
118
|
+
MCPClientTelemetryEvent(
|
|
119
|
+
server_name=self.server_name,
|
|
120
|
+
command=self.command,
|
|
121
|
+
tools_discovered=len(self._tools),
|
|
122
|
+
version=get_browser_use_version(),
|
|
123
|
+
action='connect',
|
|
124
|
+
duration_seconds=duration,
|
|
125
|
+
error_message=error_msg,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def _run_stdio_client(self, server_params: StdioServerParameters):
|
|
130
|
+
"""Run the stdio client connection in a background task."""
|
|
131
|
+
try:
|
|
132
|
+
async with stdio_client(server_params) as (read_stream, write_stream):
|
|
133
|
+
self._read_stream = read_stream
|
|
134
|
+
self._write_stream = write_stream
|
|
135
|
+
|
|
136
|
+
# Create and initialize session
|
|
137
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
138
|
+
self.session = session
|
|
139
|
+
|
|
140
|
+
# Initialize the connection
|
|
141
|
+
await session.initialize()
|
|
142
|
+
|
|
143
|
+
# Discover available tools
|
|
144
|
+
tools_response = await session.list_tools()
|
|
145
|
+
self._tools = {tool.name: tool for tool in tools_response.tools}
|
|
146
|
+
|
|
147
|
+
# Mark as connected
|
|
148
|
+
self._connected = True
|
|
149
|
+
|
|
150
|
+
# Keep the connection alive until disconnect is called
|
|
151
|
+
await self._disconnect_event.wait()
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f'MCP server connection error: {e}')
|
|
155
|
+
self._connected = False
|
|
156
|
+
raise
|
|
157
|
+
finally:
|
|
158
|
+
self._connected = False
|
|
159
|
+
self.session = None
|
|
160
|
+
|
|
161
|
+
async def disconnect(self) -> None:
|
|
162
|
+
"""Disconnect from the MCP server."""
|
|
163
|
+
if not self._connected:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
start_time = time.time()
|
|
167
|
+
error_msg = None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
logger.info(f"🔌 Disconnecting from MCP server '{self.server_name}'")
|
|
171
|
+
|
|
172
|
+
# Signal disconnect
|
|
173
|
+
self._connected = False
|
|
174
|
+
self._disconnect_event.set()
|
|
175
|
+
|
|
176
|
+
# Wait for stdio task to finish
|
|
177
|
+
if self._stdio_task:
|
|
178
|
+
try:
|
|
179
|
+
await asyncio.wait_for(self._stdio_task, timeout=2.0)
|
|
180
|
+
except TimeoutError:
|
|
181
|
+
logger.warning(f"Timeout waiting for MCP server '{self.server_name}' to disconnect")
|
|
182
|
+
self._stdio_task.cancel()
|
|
183
|
+
try:
|
|
184
|
+
await self._stdio_task
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
self._tools.clear()
|
|
189
|
+
self._registered_actions.clear()
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
error_msg = str(e)
|
|
193
|
+
logger.error(f'Error disconnecting from MCP server: {e}')
|
|
194
|
+
finally:
|
|
195
|
+
# Capture telemetry for disconnect action
|
|
196
|
+
duration = time.time() - start_time
|
|
197
|
+
self._telemetry.capture(
|
|
198
|
+
MCPClientTelemetryEvent(
|
|
199
|
+
server_name=self.server_name,
|
|
200
|
+
command=self.command,
|
|
201
|
+
tools_discovered=0, # Tools cleared on disconnect
|
|
202
|
+
version=get_browser_use_version(),
|
|
203
|
+
action='disconnect',
|
|
204
|
+
duration_seconds=duration,
|
|
205
|
+
error_message=error_msg,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
self._telemetry.flush()
|
|
209
|
+
|
|
210
|
+
async def register_to_tools(
|
|
211
|
+
self,
|
|
212
|
+
tools: Tools,
|
|
213
|
+
tool_filter: list[str] | None = None,
|
|
214
|
+
prefix: str | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Register MCP tools as actions in the browser-use tools.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
tools: Browser-use tools to register actions to
|
|
220
|
+
tool_filter: Optional list of tool names to register (None = all tools)
|
|
221
|
+
prefix: Optional prefix to add to action names (e.g., "playwright_")
|
|
222
|
+
"""
|
|
223
|
+
if not self._connected:
|
|
224
|
+
await self.connect()
|
|
225
|
+
|
|
226
|
+
registry = tools.registry
|
|
227
|
+
|
|
228
|
+
for tool_name, tool in self._tools.items():
|
|
229
|
+
# Skip if not in filter
|
|
230
|
+
if tool_filter and tool_name not in tool_filter:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Apply prefix if specified
|
|
234
|
+
action_name = f'{prefix}{tool_name}' if prefix else tool_name
|
|
235
|
+
|
|
236
|
+
# Skip if already registered
|
|
237
|
+
if action_name in self._registered_actions:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Register the tool as an action
|
|
241
|
+
self._register_tool_as_action(registry, action_name, tool)
|
|
242
|
+
self._registered_actions.add(action_name)
|
|
243
|
+
|
|
244
|
+
logger.info(f"✅ Registered {len(self._registered_actions)} MCP tools from '{self.server_name}' as browser-use actions")
|
|
245
|
+
|
|
246
|
+
def _register_tool_as_action(self, registry: Registry, action_name: str, tool: Any) -> None:
|
|
247
|
+
"""Register a single MCP tool as a browser-use action.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
registry: Browser-use registry to register action to
|
|
251
|
+
action_name: Name for the registered action
|
|
252
|
+
tool: MCP Tool object with schema information
|
|
253
|
+
"""
|
|
254
|
+
# Parse tool parameters to create Pydantic model
|
|
255
|
+
param_fields = {}
|
|
256
|
+
|
|
257
|
+
if tool.inputSchema:
|
|
258
|
+
# MCP tools use JSON Schema for parameters
|
|
259
|
+
properties = tool.inputSchema.get('properties', {})
|
|
260
|
+
required = set(tool.inputSchema.get('required', []))
|
|
261
|
+
|
|
262
|
+
for param_name, param_schema in properties.items():
|
|
263
|
+
# Convert JSON Schema type to Python type
|
|
264
|
+
param_type = self._json_schema_to_python_type(param_schema, f'{action_name}_{param_name}')
|
|
265
|
+
|
|
266
|
+
# Determine if field is required and handle defaults
|
|
267
|
+
if param_name in required:
|
|
268
|
+
default = ... # Required field
|
|
269
|
+
else:
|
|
270
|
+
# Optional field - make type optional and handle default
|
|
271
|
+
param_type = param_type | None
|
|
272
|
+
if 'default' in param_schema:
|
|
273
|
+
default = param_schema['default']
|
|
274
|
+
else:
|
|
275
|
+
default = None
|
|
276
|
+
|
|
277
|
+
# Add field with description if available
|
|
278
|
+
field_kwargs = {}
|
|
279
|
+
if 'description' in param_schema:
|
|
280
|
+
field_kwargs['description'] = param_schema['description']
|
|
281
|
+
|
|
282
|
+
param_fields[param_name] = (param_type, Field(default, **field_kwargs))
|
|
283
|
+
|
|
284
|
+
# Create Pydantic model for the tool parameters
|
|
285
|
+
if param_fields:
|
|
286
|
+
# Create a BaseModel class with proper configuration
|
|
287
|
+
class ConfiguredBaseModel(BaseModel):
|
|
288
|
+
model_config = ConfigDict(extra='forbid', validate_by_name=True, validate_by_alias=True)
|
|
289
|
+
|
|
290
|
+
param_model = create_model(f'{action_name}_Params', __base__=ConfiguredBaseModel, **param_fields)
|
|
291
|
+
else:
|
|
292
|
+
# No parameters - create empty model
|
|
293
|
+
param_model = None
|
|
294
|
+
|
|
295
|
+
# Determine if this is a browser-specific tool
|
|
296
|
+
is_browser_tool = tool.name.startswith('browser_') or 'page' in tool.name.lower()
|
|
297
|
+
|
|
298
|
+
# Set up action filters
|
|
299
|
+
domains = None
|
|
300
|
+
# Note: page_filter has been removed since we no longer use Page objects
|
|
301
|
+
# Browser tools filtering would need to be done via domain filters instead
|
|
302
|
+
|
|
303
|
+
# Create async wrapper function for the MCP tool
|
|
304
|
+
# Need to define function with explicit parameters to satisfy registry validation
|
|
305
|
+
if param_model:
|
|
306
|
+
# Type 1: Function takes param model as first parameter
|
|
307
|
+
async def mcp_action_wrapper(params: param_model) -> ActionResult: # type: ignore[no-redef]
|
|
308
|
+
"""Wrapper function that calls the MCP tool."""
|
|
309
|
+
if not self.session or not self._connected:
|
|
310
|
+
return ActionResult(error=f"MCP server '{self.server_name}' not connected", success=False)
|
|
311
|
+
|
|
312
|
+
# Convert pydantic model to dict for MCP call
|
|
313
|
+
tool_params = params.model_dump(exclude_none=True)
|
|
314
|
+
|
|
315
|
+
logger.debug(f"🔧 Calling MCP tool '{tool.name}' with params: {tool_params}")
|
|
316
|
+
|
|
317
|
+
start_time = time.time()
|
|
318
|
+
error_msg = None
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
# Call the MCP tool
|
|
322
|
+
result = await self.session.call_tool(tool.name, tool_params)
|
|
323
|
+
|
|
324
|
+
# Convert MCP result to ActionResult
|
|
325
|
+
extracted_content = self._format_mcp_result(result)
|
|
326
|
+
|
|
327
|
+
return ActionResult(
|
|
328
|
+
extracted_content=extracted_content,
|
|
329
|
+
long_term_memory=f"Used MCP tool '{tool.name}' from {self.server_name}",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
error_msg = f"MCP tool '{tool.name}' failed: {str(e)}"
|
|
334
|
+
logger.error(error_msg)
|
|
335
|
+
return ActionResult(error=error_msg, success=False)
|
|
336
|
+
finally:
|
|
337
|
+
# Capture telemetry for tool call
|
|
338
|
+
duration = time.time() - start_time
|
|
339
|
+
self._telemetry.capture(
|
|
340
|
+
MCPClientTelemetryEvent(
|
|
341
|
+
server_name=self.server_name,
|
|
342
|
+
command=self.command,
|
|
343
|
+
tools_discovered=len(self._tools),
|
|
344
|
+
version=get_browser_use_version(),
|
|
345
|
+
action='tool_call',
|
|
346
|
+
tool_name=tool.name,
|
|
347
|
+
duration_seconds=duration,
|
|
348
|
+
error_message=error_msg,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
# No parameters - empty function signature
|
|
353
|
+
async def mcp_action_wrapper() -> ActionResult: # type: ignore[no-redef]
|
|
354
|
+
"""Wrapper function that calls the MCP tool."""
|
|
355
|
+
if not self.session or not self._connected:
|
|
356
|
+
return ActionResult(error=f"MCP server '{self.server_name}' not connected", success=False)
|
|
357
|
+
|
|
358
|
+
logger.debug(f"🔧 Calling MCP tool '{tool.name}' with no params")
|
|
359
|
+
|
|
360
|
+
start_time = time.time()
|
|
361
|
+
error_msg = None
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
# Call the MCP tool with empty params
|
|
365
|
+
result = await self.session.call_tool(tool.name, {})
|
|
366
|
+
|
|
367
|
+
# Convert MCP result to ActionResult
|
|
368
|
+
extracted_content = self._format_mcp_result(result)
|
|
369
|
+
|
|
370
|
+
return ActionResult(
|
|
371
|
+
extracted_content=extracted_content,
|
|
372
|
+
long_term_memory=f"Used MCP tool '{tool.name}' from {self.server_name}",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
except Exception as e:
|
|
376
|
+
error_msg = f"MCP tool '{tool.name}' failed: {str(e)}"
|
|
377
|
+
logger.error(error_msg)
|
|
378
|
+
return ActionResult(error=error_msg, success=False)
|
|
379
|
+
finally:
|
|
380
|
+
# Capture telemetry for tool call
|
|
381
|
+
duration = time.time() - start_time
|
|
382
|
+
self._telemetry.capture(
|
|
383
|
+
MCPClientTelemetryEvent(
|
|
384
|
+
server_name=self.server_name,
|
|
385
|
+
command=self.command,
|
|
386
|
+
tools_discovered=len(self._tools),
|
|
387
|
+
version=get_browser_use_version(),
|
|
388
|
+
action='tool_call',
|
|
389
|
+
tool_name=tool.name,
|
|
390
|
+
duration_seconds=duration,
|
|
391
|
+
error_message=error_msg,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Set function metadata for better debugging
|
|
396
|
+
mcp_action_wrapper.__name__ = action_name
|
|
397
|
+
mcp_action_wrapper.__qualname__ = f'mcp.{self.server_name}.{action_name}'
|
|
398
|
+
|
|
399
|
+
# Register the action with browser-use
|
|
400
|
+
description = tool.description or f'MCP tool from {self.server_name}: {tool.name}'
|
|
401
|
+
|
|
402
|
+
# Use the registry's action decorator
|
|
403
|
+
registry.action(description=description, param_model=param_model, domains=domains)(mcp_action_wrapper)
|
|
404
|
+
|
|
405
|
+
logger.debug(f"✅ Registered MCP tool '{tool.name}' as action '{action_name}'")
|
|
406
|
+
|
|
407
|
+
def _format_mcp_result(self, result: Any) -> str:
|
|
408
|
+
"""Format MCP tool result into a string for ActionResult.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
result: Raw result from MCP tool call
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Formatted string representation of the result
|
|
415
|
+
"""
|
|
416
|
+
# Handle different MCP result formats
|
|
417
|
+
if hasattr(result, 'content'):
|
|
418
|
+
# Structured content response
|
|
419
|
+
if isinstance(result.content, list):
|
|
420
|
+
# Multiple content items
|
|
421
|
+
parts = []
|
|
422
|
+
for item in result.content:
|
|
423
|
+
if hasattr(item, 'text'):
|
|
424
|
+
parts.append(item.text)
|
|
425
|
+
elif hasattr(item, 'type') and item.type == 'text':
|
|
426
|
+
parts.append(str(item))
|
|
427
|
+
else:
|
|
428
|
+
parts.append(str(item))
|
|
429
|
+
return '\n'.join(parts)
|
|
430
|
+
else:
|
|
431
|
+
return str(result.content)
|
|
432
|
+
elif isinstance(result, list):
|
|
433
|
+
# List of content items
|
|
434
|
+
parts = []
|
|
435
|
+
for item in result:
|
|
436
|
+
if hasattr(item, 'text'):
|
|
437
|
+
parts.append(item.text)
|
|
438
|
+
else:
|
|
439
|
+
parts.append(str(item))
|
|
440
|
+
return '\n'.join(parts)
|
|
441
|
+
else:
|
|
442
|
+
# Direct result or unknown format
|
|
443
|
+
return str(result)
|
|
444
|
+
|
|
445
|
+
def _json_schema_to_python_type(self, schema: dict, model_name: str = 'NestedModel') -> Any:
|
|
446
|
+
"""Convert JSON Schema type to Python type.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
schema: JSON Schema definition
|
|
450
|
+
model_name: Name for nested models
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Python type corresponding to the schema
|
|
454
|
+
"""
|
|
455
|
+
json_type = schema.get('type', 'string')
|
|
456
|
+
|
|
457
|
+
# Basic type mapping
|
|
458
|
+
type_mapping = {
|
|
459
|
+
'string': str,
|
|
460
|
+
'number': float,
|
|
461
|
+
'integer': int,
|
|
462
|
+
'boolean': bool,
|
|
463
|
+
'array': list,
|
|
464
|
+
'null': type(None),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
# Handle enums (they're still strings)
|
|
468
|
+
if 'enum' in schema:
|
|
469
|
+
return str
|
|
470
|
+
|
|
471
|
+
# Handle objects with nested properties
|
|
472
|
+
if json_type == 'object':
|
|
473
|
+
properties = schema.get('properties', {})
|
|
474
|
+
if properties:
|
|
475
|
+
# Create nested pydantic model for objects with properties
|
|
476
|
+
nested_fields = {}
|
|
477
|
+
required_fields = set(schema.get('required', []))
|
|
478
|
+
|
|
479
|
+
for prop_name, prop_schema in properties.items():
|
|
480
|
+
# Recursively process nested properties
|
|
481
|
+
prop_type = self._json_schema_to_python_type(prop_schema, f'{model_name}_{prop_name}')
|
|
482
|
+
|
|
483
|
+
# Determine if field is required and handle defaults
|
|
484
|
+
if prop_name in required_fields:
|
|
485
|
+
default = ... # Required field
|
|
486
|
+
else:
|
|
487
|
+
# Optional field - make type optional and handle default
|
|
488
|
+
prop_type = prop_type | None
|
|
489
|
+
if 'default' in prop_schema:
|
|
490
|
+
default = prop_schema['default']
|
|
491
|
+
else:
|
|
492
|
+
default = None
|
|
493
|
+
|
|
494
|
+
# Add field with description if available
|
|
495
|
+
field_kwargs = {}
|
|
496
|
+
if 'description' in prop_schema:
|
|
497
|
+
field_kwargs['description'] = prop_schema['description']
|
|
498
|
+
|
|
499
|
+
nested_fields[prop_name] = (prop_type, Field(default, **field_kwargs))
|
|
500
|
+
|
|
501
|
+
# Create a BaseModel class with proper configuration
|
|
502
|
+
class ConfiguredBaseModel(BaseModel):
|
|
503
|
+
model_config = ConfigDict(extra='forbid', validate_by_name=True, validate_by_alias=True)
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
# Create and return nested pydantic model
|
|
507
|
+
return create_model(model_name, __base__=ConfiguredBaseModel, **nested_fields)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.error(f'Failed to create nested model {model_name}: {e}')
|
|
510
|
+
logger.debug(f'Fields: {nested_fields}')
|
|
511
|
+
# Fallback to basic dict if model creation fails
|
|
512
|
+
return dict
|
|
513
|
+
else:
|
|
514
|
+
# Object without properties - just return dict
|
|
515
|
+
return dict
|
|
516
|
+
|
|
517
|
+
# Handle arrays with specific item types
|
|
518
|
+
if json_type == 'array':
|
|
519
|
+
if 'items' in schema:
|
|
520
|
+
# Get the item type recursively
|
|
521
|
+
item_type = self._json_schema_to_python_type(schema['items'], f'{model_name}_item')
|
|
522
|
+
# Return properly typed list
|
|
523
|
+
return list[item_type]
|
|
524
|
+
else:
|
|
525
|
+
# Array without item type specification
|
|
526
|
+
return list
|
|
527
|
+
|
|
528
|
+
# Get base type for non-object types
|
|
529
|
+
base_type = type_mapping.get(json_type, str)
|
|
530
|
+
|
|
531
|
+
# Handle nullable/optional types
|
|
532
|
+
if schema.get('nullable', False) or json_type == 'null':
|
|
533
|
+
return base_type | None
|
|
534
|
+
|
|
535
|
+
return base_type
|
|
536
|
+
|
|
537
|
+
async def __aenter__(self):
|
|
538
|
+
"""Async context manager entry."""
|
|
539
|
+
await self.connect()
|
|
540
|
+
return self
|
|
541
|
+
|
|
542
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
543
|
+
"""Async context manager exit."""
|
|
544
|
+
await self.disconnect()
|