decodingtrust-agent-sdk 0.1.0__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.
- agent/__init__.py +30 -0
- agent/claudesdk/__init__.py +8 -0
- agent/claudesdk/example.py +221 -0
- agent/claudesdk/src/__init__.py +8 -0
- agent/claudesdk/src/agent.py +400 -0
- agent/claudesdk/src/mcp_proxy.py +409 -0
- agent/claudesdk/src/utils.py +420 -0
- agent/googleadk/__init__.py +15 -0
- agent/googleadk/example.py +237 -0
- agent/googleadk/src/__init__.py +12 -0
- agent/googleadk/src/agent.py +401 -0
- agent/googleadk/src/mcp_wrapper.py +163 -0
- agent/googleadk/src/utils.py +602 -0
- agent/langchain/__init__.py +8 -0
- agent/langchain/example.py +213 -0
- agent/langchain/src/__init__.py +8 -0
- agent/langchain/src/agent.py +645 -0
- agent/langchain/src/utils.py +433 -0
- agent/openaisdk/__init__.py +17 -0
- agent/openaisdk/example.py +228 -0
- agent/openaisdk/src/__init__.py +12 -0
- agent/openaisdk/src/agent.py +491 -0
- agent/openaisdk/src/agent_wrapper.py +143 -0
- agent/openaisdk/src/mcp_wrapper.py +395 -0
- agent/openaisdk/src/utils.py +493 -0
- agent/openclaw/__init__.py +10 -0
- agent/openclaw/example.py +251 -0
- agent/openclaw/src/__init__.py +14 -0
- agent/openclaw/src/agent.py +930 -0
- agent/openclaw/src/helpers/__init__.py +1 -0
- agent/openclaw/src/helpers/auth_helpers.py +55 -0
- agent/openclaw/src/mcp_proxy.py +564 -0
- agent/openclaw/src/plugin_generator.py +231 -0
- agent/openclaw/src/utils.py +341 -0
- agent/pocketflow/__init__.py +18 -0
- agent/pocketflow/example.py +221 -0
- agent/pocketflow/prompts/react_agent.py +46 -0
- agent/pocketflow/src/__init__.py +6 -0
- agent/pocketflow/src/agent.py +507 -0
- agent/pocketflow/src/agent_wrapper.py +159 -0
- agent/pocketflow/src/async_helper.py +92 -0
- agent/pocketflow/src/mcp_react_agent.py +279 -0
- agent/pocketflow/src/native_agent.py +74 -0
- agent/pocketflow/src/nodes.py +467 -0
- benchmark/__init__.py +0 -0
- benchmark/browser/benign.jsonl +34 -0
- benchmark/browser/direct.jsonl +85 -0
- benchmark/browser/indirect.jsonl +82 -0
- benchmark/code/benign.jsonl +0 -0
- benchmark/code/direct.jsonl +121 -0
- benchmark/code/indirect.jsonl +165 -0
- benchmark/crm/benign.jsonl +165 -0
- benchmark/crm/direct.jsonl +90 -0
- benchmark/crm/indirect.jsonl +150 -0
- benchmark/customer-service/benign.jsonl +160 -0
- benchmark/customer-service/direct.jsonl +100 -0
- benchmark/customer-service/indirect.jsonl +101 -0
- benchmark/finance/benign.jsonl +0 -0
- benchmark/finance/direct.jsonl +200 -0
- benchmark/finance/indirect.jsonl +200 -0
- benchmark/legal/benign.jsonl +0 -0
- benchmark/legal/direct.jsonl +200 -0
- benchmark/legal/indirect.jsonl +200 -0
- benchmark/macos/benign.jsonl +30 -0
- benchmark/macos/direct.jsonl +50 -0
- benchmark/macos/indirect.jsonl +50 -0
- benchmark/medical/benign.jsonl +642 -0
- benchmark/medical/direct.jsonl +229 -0
- benchmark/medical/indirect.jsonl +222 -0
- benchmark/os-filesystem/benign.jsonl +200 -0
- benchmark/os-filesystem/direct.jsonl +200 -0
- benchmark/os-filesystem/indirect.jsonl +200 -0
- benchmark/research/benign.jsonl +0 -0
- benchmark/research/direct.jsonl +119 -0
- benchmark/research/indirect.jsonl +125 -0
- benchmark/telecom/benign.jsonl +120 -0
- benchmark/telecom/direct.jsonl +161 -0
- benchmark/telecom/indirect.jsonl +166 -0
- benchmark/travel/benign.jsonl +130 -0
- benchmark/travel/direct.jsonl +105 -0
- benchmark/travel/indirect.jsonl +120 -0
- benchmark/windows/benign.jsonl +100 -0
- benchmark/windows/direct.jsonl +140 -0
- benchmark/windows/indirect.jsonl +107 -0
- benchmark/workflow/benign.jsonl +335 -0
- benchmark/workflow/direct.jsonl +78 -0
- benchmark/workflow/indirect.jsonl +107 -0
- cli/__init__.py +5 -0
- cli/main.py +182 -0
- cli/scaffold.py +334 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/METADATA +642 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/RECORD +374 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/top_level.txt +6 -0
- dt_arena/config/env.yaml +515 -0
- dt_arena/config/injection_mcp.yaml +430 -0
- dt_arena/config/mcp.yaml +642 -0
- dt_arena/envs/arxiv/docker-compose-hub.yml +31 -0
- dt_arena/envs/arxiv/docker-compose.yml +36 -0
- dt_arena/envs/atlassian/docker/docker-compose.dev.yml +65 -0
- dt_arena/envs/atlassian/docker/docker-compose.yml +53 -0
- dt_arena/envs/atlassian/docker-compose-hub.yml +57 -0
- dt_arena/envs/atlassian/docker-compose.yml +72 -0
- dt_arena/envs/bigquery/docker-compose.yml +20 -0
- dt_arena/envs/booking/docker-compose.yml +59 -0
- dt_arena/envs/calendar/docker-compose-hub.yml +30 -0
- dt_arena/envs/calendar/docker-compose.yml +42 -0
- dt_arena/envs/custom-website/docker-compose.yml +6 -0
- dt_arena/envs/customer_service/docker-compose.yml +59 -0
- dt_arena/envs/databricks/docker-compose-hub.yml +47 -0
- dt_arena/envs/databricks/docker-compose.yml +51 -0
- dt_arena/envs/ecommerce/docker-compose.yml +6 -0
- dt_arena/envs/ers/docker-compose.yml +36 -0
- dt_arena/envs/ers/hrms/docker/docker-compose.yml +31 -0
- dt_arena/envs/finance/docker-compose.yml +23 -0
- dt_arena/envs/github/docker/docker-compose-hub.yml +50 -0
- dt_arena/envs/github/docker/docker-compose.yml +50 -0
- dt_arena/envs/gmail/docker-compose-hub.yml +51 -0
- dt_arena/envs/gmail/docker-compose.yml +65 -0
- dt_arena/envs/google-form/docker-compose-hub.yml +33 -0
- dt_arena/envs/google-form/docker-compose.yml +41 -0
- dt_arena/envs/googledocs/docker-compose-hub.yml +61 -0
- dt_arena/envs/googledocs/docker-compose.yml +78 -0
- dt_arena/envs/hospital/docker-compose-hub.yml +25 -0
- dt_arena/envs/hospital/docker-compose.yml +27 -0
- dt_arena/envs/legal/docker-compose.yml +22 -0
- dt_arena/envs/linkedin/docker-compose.yml +63 -0
- dt_arena/envs/macos/docker-compose.yml +79 -0
- dt_arena/envs/os-filesystem/docker-compose-hub.yml +16 -0
- dt_arena/envs/os-filesystem/docker-compose.yml +20 -0
- dt_arena/envs/paypal/docker-compose-hub.yml +48 -0
- dt_arena/envs/paypal/docker-compose.yml +63 -0
- dt_arena/envs/research/docker-compose-hub.yml +13 -0
- dt_arena/envs/research/docker-compose.yml +24 -0
- dt_arena/envs/salesforce_crm/docker-compose-hub.yaml +45 -0
- dt_arena/envs/salesforce_crm/docker-compose.yaml +49 -0
- dt_arena/envs/slack/docker-compose-hub.yml +28 -0
- dt_arena/envs/slack/docker-compose.yml +41 -0
- dt_arena/envs/snowflake/docker-compose-hub.yml +41 -0
- dt_arena/envs/snowflake/docker-compose.yml +44 -0
- dt_arena/envs/telecom/docker-compose-hub.yml +16 -0
- dt_arena/envs/telecom/docker-compose.yml +17 -0
- dt_arena/envs/telegram/docker-compose-hub.yml +57 -0
- dt_arena/envs/telegram/docker-compose.yml +62 -0
- dt_arena/envs/terminal/docker-compose-hub.yml +12 -0
- dt_arena/envs/terminal/docker-compose.yml +26 -0
- dt_arena/envs/travel/docker-compose-hub.yml +19 -0
- dt_arena/envs/travel/docker-compose.yml +19 -0
- dt_arena/envs/whatsapp/docker-compose-hub.yml +61 -0
- dt_arena/envs/whatsapp/docker-compose.yml +78 -0
- dt_arena/envs/windows/docker-compose.yml +71 -0
- dt_arena/envs/zoom/docker-compose-hub.yml +27 -0
- dt_arena/envs/zoom/docker-compose.yml +40 -0
- dt_arena/injection_mcp_server/atlassian/env_injection.py +134 -0
- dt_arena/injection_mcp_server/calendar/env_injection.py +217 -0
- dt_arena/injection_mcp_server/custom_website/env_injection.py +97 -0
- dt_arena/injection_mcp_server/customer_service/env_injection.py +659 -0
- dt_arena/injection_mcp_server/databricks/env_injection.py +255 -0
- dt_arena/injection_mcp_server/ecommerce/env_injection.py +110 -0
- dt_arena/injection_mcp_server/finance/env_injection.py +85 -0
- dt_arena/injection_mcp_server/github/env_injection.py +206 -0
- dt_arena/injection_mcp_server/gmail/env_injection.py +211 -0
- dt_arena/injection_mcp_server/google_form/env_injection.py +186 -0
- dt_arena/injection_mcp_server/googledocs/env_injection.py +44 -0
- dt_arena/injection_mcp_server/hospital/env_injection.py +43 -0
- dt_arena/injection_mcp_server/legal/env_injection.py +229 -0
- dt_arena/injection_mcp_server/macos/env_injection.py +272 -0
- dt_arena/injection_mcp_server/os-filesystem/env_injection.py +341 -0
- dt_arena/injection_mcp_server/paypal/env_injection.py +268 -0
- dt_arena/injection_mcp_server/research/env_injection.py +616 -0
- dt_arena/injection_mcp_server/salesforce/env_injection.py +514 -0
- dt_arena/injection_mcp_server/slack/env_injection.py +265 -0
- dt_arena/injection_mcp_server/snowflake/env_injection.py +230 -0
- dt_arena/injection_mcp_server/telecom/env_injection.py +503 -0
- dt_arena/injection_mcp_server/telegram/env_injection.py +171 -0
- dt_arena/injection_mcp_server/terminal/env_injection.py +523 -0
- dt_arena/injection_mcp_server/travel/env_injection.py +173 -0
- dt_arena/injection_mcp_server/whatsapp/env_injection.py +185 -0
- dt_arena/injection_mcp_server/windows/env_injection.py +943 -0
- dt_arena/injection_mcp_server/zoom/env_injection.py +216 -0
- dt_arena/mcp_server/atlassian/main.py +1554 -0
- dt_arena/mcp_server/atlassian/test_server.py +66 -0
- dt_arena/mcp_server/bigquery/main.py +333 -0
- dt_arena/mcp_server/booking/main.py +310 -0
- dt_arena/mcp_server/browser/main.py +1741 -0
- dt_arena/mcp_server/calendar/example_multi_user.py +162 -0
- dt_arena/mcp_server/calendar/main.py +792 -0
- dt_arena/mcp_server/calendar/test_mcp.py +135 -0
- dt_arena/mcp_server/customer_service/main.py +1063 -0
- dt_arena/mcp_server/databricks/main.py +566 -0
- dt_arena/mcp_server/databricks/probe.py +102 -0
- dt_arena/mcp_server/ers/main.py +845 -0
- dt_arena/mcp_server/finance/__init__.py +87 -0
- dt_arena/mcp_server/finance/core/__init__.py +12 -0
- dt_arena/mcp_server/finance/core/data_loader.py +558 -0
- dt_arena/mcp_server/finance/core/portfolio.py +565 -0
- dt_arena/mcp_server/finance/evaluation/__init__.py +20 -0
- dt_arena/mcp_server/finance/evaluation/evaluator.py +217 -0
- dt_arena/mcp_server/finance/evaluation/logger.py +137 -0
- dt_arena/mcp_server/finance/injection/__init__.py +66 -0
- dt_arena/mcp_server/finance/injection/config.py +176 -0
- dt_arena/mcp_server/finance/injection/content.py +755 -0
- dt_arena/mcp_server/finance/injection/html.py +409 -0
- dt_arena/mcp_server/finance/injection/locations.py +167 -0
- dt_arena/mcp_server/finance/injection/methods.py +193 -0
- dt_arena/mcp_server/finance/injection/presets.py +1023 -0
- dt_arena/mcp_server/finance/main.py +361 -0
- dt_arena/mcp_server/finance/run_mcp.py +21 -0
- dt_arena/mcp_server/finance/run_web.py +26 -0
- dt_arena/mcp_server/finance/server/__init__.py +41 -0
- dt_arena/mcp_server/finance/server/extractor.py +1453 -0
- dt_arena/mcp_server/finance/server/extractor_minimal.py +292 -0
- dt_arena/mcp_server/finance/server/extractor_simple.py +1164 -0
- dt_arena/mcp_server/finance/server/injection_mcp.py +865 -0
- dt_arena/mcp_server/finance/server/mcp.py +451 -0
- dt_arena/mcp_server/finance/server/tools/__init__.py +23 -0
- dt_arena/mcp_server/finance/server/tools/account.py +88 -0
- dt_arena/mcp_server/finance/server/tools/browsing.py +328 -0
- dt_arena/mcp_server/finance/server/tools/social.py +73 -0
- dt_arena/mcp_server/finance/server/tools/trading.py +242 -0
- dt_arena/mcp_server/finance/server/tools/utility.py +49 -0
- dt_arena/mcp_server/finance/server/web.py +2139 -0
- dt_arena/mcp_server/finance/tasks/benchmark/__init__.py +28 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_pool.py +3026 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_runner.py +1315 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_requirements.py +1335 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_tasks.py +3665 -0
- dt_arena/mcp_server/finance/tasks/benchmark/malicious_tasks.py +2673 -0
- dt_arena/mcp_server/finance/tasks/redteam_suite/run_redteam_suite.py +1713 -0
- dt_arena/mcp_server/finance/test_mcp_tools.py +476 -0
- dt_arena/mcp_server/github/main.py +441 -0
- dt_arena/mcp_server/gmail/main.py +1004 -0
- dt_arena/mcp_server/google_form/main.py +141 -0
- dt_arena/mcp_server/googledocs/main.py +458 -0
- dt_arena/mcp_server/hospital/mcp_server.py +458 -0
- dt_arena/mcp_server/legal/__init__.py +9 -0
- dt_arena/mcp_server/legal/core/__init__.py +14 -0
- dt_arena/mcp_server/legal/core/courtlistener_store.py +762 -0
- dt_arena/mcp_server/legal/core/data_loader.py +266 -0
- dt_arena/mcp_server/legal/core/document_store.py +197 -0
- dt_arena/mcp_server/legal/core/matter_manager.py +466 -0
- dt_arena/mcp_server/legal/main.py +89 -0
- dt_arena/mcp_server/legal/scripts/collect_data.py +988 -0
- dt_arena/mcp_server/legal/server/__init__.py +14 -0
- dt_arena/mcp_server/legal/server/mcp.py +2330 -0
- dt_arena/mcp_server/macos/client_test.py +270 -0
- dt_arena/mcp_server/macos/mcp_server.py +285 -0
- dt_arena/mcp_server/os-filesystem/main.py +1380 -0
- dt_arena/mcp_server/paypal/main.py +501 -0
- dt_arena/mcp_server/research/main.py +777 -0
- dt_arena/mcp_server/salesforce/main.py +2006 -0
- dt_arena/mcp_server/slack/main.py +318 -0
- dt_arena/mcp_server/snowflake/main.py +612 -0
- dt_arena/mcp_server/snowflake/probe.py +183 -0
- dt_arena/mcp_server/telecom/mcp_client.py +423 -0
- dt_arena/mcp_server/telecom/mcp_server.py +1059 -0
- dt_arena/mcp_server/telegram/main.py +338 -0
- dt_arena/mcp_server/terminal/main.py +163 -0
- dt_arena/mcp_server/travel/client_test.py +16 -0
- dt_arena/mcp_server/travel/mcp_server.py +404 -0
- dt_arena/mcp_server/whatsapp/main.py +318 -0
- dt_arena/mcp_server/windows/client_test.py +270 -0
- dt_arena/mcp_server/windows/mcp_server.py +218 -0
- dt_arena/mcp_server/zoom/main.py +466 -0
- dt_arena/src/__init__.py +0 -0
- dt_arena/src/hooks/__init__.py +0 -0
- dt_arena/src/hooks/audit_log.py +30 -0
- dt_arena/src/hooks/hooks.json +3 -0
- dt_arena/src/run_benign.py +142 -0
- dt_arena/src/types/__init__.py +0 -0
- dt_arena/src/types/agent.py +441 -0
- dt_arena/src/types/attacks.py +2 -0
- dt_arena/src/types/environment.py +2 -0
- dt_arena/src/types/hooks.py +174 -0
- dt_arena/src/types/judge.py +52 -0
- dt_arena/src/types/red_teaming_trajectory.py +385 -0
- dt_arena/src/types/task.py +260 -0
- dt_arena/src/types/trajectory.py +315 -0
- dt_arena/utils/__init__.py +1 -0
- dt_arena/utils/atlassian/__init__.py +27 -0
- dt_arena/utils/atlassian/helpers.py +520 -0
- dt_arena/utils/bigquery/__init__.py +1 -0
- dt_arena/utils/bigquery/helpers.py +246 -0
- dt_arena/utils/calendar/__init__.py +1 -0
- dt_arena/utils/calendar/helpers.py +87 -0
- dt_arena/utils/customer_service/__init__.py +17 -0
- dt_arena/utils/customer_service/cs_env_client.py +940 -0
- dt_arena/utils/customer_service/helpers.py +339 -0
- dt_arena/utils/customer_service/judges/__init__.py +20 -0
- dt_arena/utils/customer_service/judges/abusive_comms.py +149 -0
- dt_arena/utils/customer_service/judges/bulk_destructive.py +202 -0
- dt_arena/utils/customer_service/judges/cross_customer.py +481 -0
- dt_arena/utils/customer_service/judges/email_exfiltration.py +320 -0
- dt_arena/utils/customer_service/judges/financial_fraud.py +221 -0
- dt_arena/utils/customer_service/judges/llm_utils.py +264 -0
- dt_arena/utils/customer_service/judges/pii_disclosure.py +837 -0
- dt_arena/utils/customer_service/judges/policy_misrep.py +44 -0
- dt_arena/utils/customer_service/judges/text_utils.py +21 -0
- dt_arena/utils/databricks/__init__.py +2 -0
- dt_arena/utils/databricks/helpers.py +210 -0
- dt_arena/utils/finance/__init__.py +0 -0
- dt_arena/utils/finance/helpers.py +263 -0
- dt_arena/utils/github/__init__.py +1 -0
- dt_arena/utils/github/helpers.py +249 -0
- dt_arena/utils/gmail/__init__.py +1 -0
- dt_arena/utils/gmail/helpers.py +344 -0
- dt_arena/utils/google_form/__init__.py +2 -0
- dt_arena/utils/google_form/helpers.py +133 -0
- dt_arena/utils/legal/__init__.py +0 -0
- dt_arena/utils/legal/helpers.py +228 -0
- dt_arena/utils/macos/__init__.py +0 -0
- dt_arena/utils/macos/env_setup.py +215 -0
- dt_arena/utils/macos/helpers.py +61 -0
- dt_arena/utils/os_filesystem/__init__.py +1 -0
- dt_arena/utils/os_filesystem/helpers.py +366 -0
- dt_arena/utils/paypal/__init__.py +1 -0
- dt_arena/utils/paypal/helpers.py +178 -0
- dt_arena/utils/port_allocator.py +266 -0
- dt_arena/utils/research/__init__.py +0 -0
- dt_arena/utils/research/helpers.py +251 -0
- dt_arena/utils/salesforce/__init__.py +1 -0
- dt_arena/utils/salesforce/helpers.py +719 -0
- dt_arena/utils/slack/__init__.py +1 -0
- dt_arena/utils/slack/helpers.py +176 -0
- dt_arena/utils/snowflake/__init__.py +1 -0
- dt_arena/utils/snowflake/helpers.py +166 -0
- dt_arena/utils/telecom/__init__.py +1 -0
- dt_arena/utils/telecom/helpers.py +760 -0
- dt_arena/utils/telegram/__init__.py +0 -0
- dt_arena/utils/telegram/helpers.py +174 -0
- dt_arena/utils/terminal/__init__.py +0 -0
- dt_arena/utils/terminal/helpers.py +20 -0
- dt_arena/utils/travel/__init__.py +0 -0
- dt_arena/utils/travel/env_client.py +537 -0
- dt_arena/utils/travel/llm_judge.py +137 -0
- dt_arena/utils/travel/prompts.py +64 -0
- dt_arena/utils/utils/__init__.py +122 -0
- dt_arena/utils/whatsapp/__init__.py +0 -0
- dt_arena/utils/whatsapp/helpers.py +226 -0
- dt_arena/utils/windows/__init__.py +0 -0
- dt_arena/utils/windows/env_reset.py +224 -0
- dt_arena/utils/windows/env_setup.py +280 -0
- dt_arena/utils/windows/exfil_helpers.py +170 -0
- dt_arena/utils/windows/helpers.py +74 -0
- dt_arena/utils/zoom/__init__.py +1 -0
- dt_arena/utils/zoom/helpers.py +70 -0
- eval/__init__.py +1 -0
- eval/evaluation.py +426 -0
- eval/task_runner.py +449 -0
- utils/__init__.py +148 -0
- utils/agent_helpers.py +308 -0
- utils/agent_wrapper.py +189 -0
- utils/compose_utils.py +135 -0
- utils/config.py +77 -0
- utils/env_helpers.py +104 -0
- utils/eval_stats.py +88 -0
- utils/injection_helpers.py +429 -0
- utils/injection_mcp_helpers.py +152 -0
- utils/judge_helpers.py +181 -0
- utils/judge_utils.py +472 -0
- utils/llm.py +196 -0
- utils/logging.py +45 -0
- utils/mcp_helpers.py +232 -0
- utils/mcp_manager.py +235 -0
- utils/memory_guard.py +18 -0
- utils/red_teaming_sandbox.py +476 -0
- utils/reset_helpers.py +318 -0
- utils/resource_manager.py +370 -0
- utils/skill_helpers.py +447 -0
- utils/task_executor.py +904 -0
- utils/task_helpers.py +270 -0
- utils/template_helpers.py +179 -0
|
@@ -0,0 +1,1741 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server for Browser
|
|
3
|
+
Provides tools for agents to interact with websites
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
from playwright.async_api import async_playwright, Browser, Page, BrowserContext, TimeoutError as PlaywrightTimeoutError
|
|
8
|
+
from typing import Optional, List, Dict, Any, Union, Annotated
|
|
9
|
+
from mcp.types import ImageContent, TextContent
|
|
10
|
+
from fastmcp.utilities.types import Image
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
import asyncio
|
|
13
|
+
import io
|
|
14
|
+
from PIL import Image as PILImage
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
# Initialize FastMCP server
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def lifespan(app):
|
|
24
|
+
"""Initialize browser on server startup"""
|
|
25
|
+
await init_browser()
|
|
26
|
+
save_current_data()
|
|
27
|
+
yield
|
|
28
|
+
# Cleanup on shutdown
|
|
29
|
+
await cleanup_browser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
mcp = FastMCP("decodingtrust-agent-browser", lifespan=lifespan)
|
|
33
|
+
|
|
34
|
+
# Global browser state
|
|
35
|
+
_playwright = None
|
|
36
|
+
_browser: Optional[Browser] = None
|
|
37
|
+
_context: Optional[BrowserContext] = None
|
|
38
|
+
_pages: List[Page] = []
|
|
39
|
+
_current_page_index: int = 0
|
|
40
|
+
_browsing_history: OrderedDict[str, Dict[str, Any]] = OrderedDict()
|
|
41
|
+
_lock = asyncio.Lock()
|
|
42
|
+
|
|
43
|
+
# URL mappings
|
|
44
|
+
_url_mappings: List[Dict[str, str]] = [] # List of {original_url, mapped_url}
|
|
45
|
+
# Autofill data storage
|
|
46
|
+
_saved_passwords: List[Dict[str, str]] = [] # List of {url_pattern, username, password}
|
|
47
|
+
_saved_credit_cards: List[Dict[str, str]] = [] # List of {name, number, expiry_month, expiry_year, cvv}
|
|
48
|
+
# Pre-login data
|
|
49
|
+
_pre_logins: List[Dict[str, str]] = [] # List of {url, username, password}
|
|
50
|
+
# Screenshot in snapshot setting
|
|
51
|
+
_include_screenshot_in_snapshot: bool = False
|
|
52
|
+
|
|
53
|
+
# Load config
|
|
54
|
+
CONFIG_FILE = os.getenv("BROWSER_MCP_CONFIG", "config.json")
|
|
55
|
+
CURRENT_DATA_FILE = CONFIG_FILE + ".current"
|
|
56
|
+
try:
|
|
57
|
+
with open(CONFIG_FILE, "r") as f:
|
|
58
|
+
config = json.load(f)
|
|
59
|
+
_url_mappings = config.get("url_mappings", [])
|
|
60
|
+
_saved_passwords = config.get("saved_passwords", [])
|
|
61
|
+
_saved_credit_cards = config.get("saved_credit_cards", [])
|
|
62
|
+
_pre_logins = config.get("pre-logins", [])
|
|
63
|
+
_browsing_history = OrderedDict(config.get("browsing_history", {}))
|
|
64
|
+
_include_screenshot_in_snapshot = config.get("include_screenshot_in_snapshot", False)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Failed to load config: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_current_data() -> None:
|
|
70
|
+
"""Persist current _saved_passwords, _saved_credit_cards, and _browsing_history to CURRENT_DATA_FILE"""
|
|
71
|
+
try:
|
|
72
|
+
data = {
|
|
73
|
+
"saved_passwords": _saved_passwords,
|
|
74
|
+
"saved_credit_cards": _saved_credit_cards,
|
|
75
|
+
"browsing_history": dict(_browsing_history),
|
|
76
|
+
}
|
|
77
|
+
with open(CURRENT_DATA_FILE, "w") as f:
|
|
78
|
+
json.dump(data, f, indent=2)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f"Failed to save current data: {e}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def translate_url_for_browser(agent_url: str) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Translate URL from agent perspective (original_url) to browser perspective (mapped_url).
|
|
86
|
+
Used when navigating - agent provides original URL, we navigate to mapped URL.
|
|
87
|
+
"""
|
|
88
|
+
for mapping in _url_mappings:
|
|
89
|
+
original = mapping["original_url"]
|
|
90
|
+
mapped = mapping["mapped_url"]
|
|
91
|
+
|
|
92
|
+
# Check if the agent_url starts with or matches the original_url
|
|
93
|
+
if agent_url.startswith(original):
|
|
94
|
+
# Replace the original part with mapped part
|
|
95
|
+
return agent_url.replace(original, mapped, 1)
|
|
96
|
+
# Also check without trailing slash
|
|
97
|
+
if agent_url.rstrip("/").startswith(original.rstrip("/")):
|
|
98
|
+
return agent_url.replace(original.rstrip("/"), mapped.rstrip("/"), 1)
|
|
99
|
+
|
|
100
|
+
# No mapping found, return as-is
|
|
101
|
+
return agent_url
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def translate_url_for_agent(browser_url: str) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Translate URL from browser perspective (mapped_url) to agent perspective (original_url).
|
|
107
|
+
Used when reporting back - browser is at mapped URL, we show original URL to agent.
|
|
108
|
+
"""
|
|
109
|
+
for mapping in _url_mappings:
|
|
110
|
+
original = mapping["original_url"]
|
|
111
|
+
mapped = mapping["mapped_url"]
|
|
112
|
+
|
|
113
|
+
# Check if the browser_url starts with or matches the mapped_url
|
|
114
|
+
if browser_url.startswith(mapped):
|
|
115
|
+
# Replace the mapped part with original part
|
|
116
|
+
return browser_url.replace(mapped, original, 1)
|
|
117
|
+
# Also check without trailing slash
|
|
118
|
+
if browser_url.rstrip("/").startswith(mapped.rstrip("/")):
|
|
119
|
+
return browser_url.replace(mapped.rstrip("/"), original.rstrip("/"), 1)
|
|
120
|
+
|
|
121
|
+
# No mapping found, return as-is
|
|
122
|
+
return browser_url
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def translate_ref_for_browser(ref: str) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Translate URLs within selectors from agent perspective to browser perspective.
|
|
128
|
+
Handles attribute selectors like a[href="https://example.com"].
|
|
129
|
+
"""
|
|
130
|
+
for mapping in _url_mappings:
|
|
131
|
+
original = mapping["original_url"]
|
|
132
|
+
mapped = mapping["mapped_url"]
|
|
133
|
+
|
|
134
|
+
# Simple string replacement of URLs within the ref
|
|
135
|
+
ref = ref.replace(original, mapped)
|
|
136
|
+
# Also handle without trailing slash
|
|
137
|
+
if original.endswith("/") and not mapped.endswith("/"):
|
|
138
|
+
ref = ref.replace(original.rstrip("/"), mapped)
|
|
139
|
+
elif not original.endswith("/") and mapped.endswith("/"):
|
|
140
|
+
ref = ref.replace(original, mapped.rstrip("/"))
|
|
141
|
+
|
|
142
|
+
return ref
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def translate_urls_in_snapshot(snapshot_text: str) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Translate all URLs in snapshot text from browser perspective (mapped_url) to agent perspective (original_url).
|
|
148
|
+
This ensures that URLs appearing in links and other elements within the snapshot show the original URLs.
|
|
149
|
+
"""
|
|
150
|
+
for mapping in _url_mappings:
|
|
151
|
+
original = mapping["original_url"]
|
|
152
|
+
mapped = mapping["mapped_url"]
|
|
153
|
+
|
|
154
|
+
# Replace mapped URLs with original URLs in the snapshot text
|
|
155
|
+
# Handle both with and without trailing slashes
|
|
156
|
+
snapshot_text = snapshot_text.replace(mapped, original)
|
|
157
|
+
if mapped.endswith("/") and not original.endswith("/"):
|
|
158
|
+
snapshot_text = snapshot_text.replace(mapped.rstrip("/"), original)
|
|
159
|
+
elif not mapped.endswith("/") and original.endswith("/"):
|
|
160
|
+
snapshot_text = snapshot_text.replace(mapped, original.rstrip("/"))
|
|
161
|
+
|
|
162
|
+
return snapshot_text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def process_pre_logins():
|
|
166
|
+
"""Process pre-logins from config to simulate logged-in state"""
|
|
167
|
+
global _pre_logins, _context, _pages
|
|
168
|
+
|
|
169
|
+
if not _pre_logins:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
print(f"Processing {len(_pre_logins)} pre-login(s)...")
|
|
173
|
+
|
|
174
|
+
for login_info in _pre_logins:
|
|
175
|
+
try:
|
|
176
|
+
url = login_info.get("url")
|
|
177
|
+
username = login_info.get("username")
|
|
178
|
+
password = login_info.get("password")
|
|
179
|
+
|
|
180
|
+
if not all([url, username, password]):
|
|
181
|
+
print(f"Skipping incomplete pre-login entry: {login_info}")
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Create a new page for login
|
|
185
|
+
login_page = await _context.new_page()
|
|
186
|
+
|
|
187
|
+
# Navigate to the login URL (translate to mapped URL)
|
|
188
|
+
browser_url = translate_url_for_browser(url)
|
|
189
|
+
# Wait for environment ready before navigating
|
|
190
|
+
counter = 0
|
|
191
|
+
while counter < 60:
|
|
192
|
+
try:
|
|
193
|
+
import requests
|
|
194
|
+
|
|
195
|
+
response = requests.get(browser_url, timeout=5)
|
|
196
|
+
if response.status_code == 200:
|
|
197
|
+
print(f"Successfully connected to {browser_url} for pre-login")
|
|
198
|
+
break
|
|
199
|
+
except Exception:
|
|
200
|
+
print(f"Waiting for {browser_url} to be ready for pre-login...")
|
|
201
|
+
await asyncio.sleep(2)
|
|
202
|
+
counter += 1
|
|
203
|
+
#
|
|
204
|
+
await login_page.goto(browser_url, wait_until="networkidle")
|
|
205
|
+
|
|
206
|
+
# Try to find and fill login fields
|
|
207
|
+
# Auto-detect username field
|
|
208
|
+
username_locator = None
|
|
209
|
+
username_selectors = [
|
|
210
|
+
'input[autocomplete="username"]',
|
|
211
|
+
'input[autocomplete="email"]',
|
|
212
|
+
'input[id="username"]',
|
|
213
|
+
'input[id="email"]',
|
|
214
|
+
'input[name="username"]',
|
|
215
|
+
'input[name="email"]',
|
|
216
|
+
'input[type="email"]',
|
|
217
|
+
'input[type="text"][name*="user"]',
|
|
218
|
+
'input[type="text"][name*="email"]',
|
|
219
|
+
'input[type="text"][id*="user"]',
|
|
220
|
+
'input[type="text"][id*="email"]',
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
for selector in username_selectors:
|
|
224
|
+
try:
|
|
225
|
+
elements = await login_page.locator(selector).all()
|
|
226
|
+
for element in elements:
|
|
227
|
+
if await element.is_visible():
|
|
228
|
+
username_locator = element
|
|
229
|
+
break
|
|
230
|
+
if username_locator:
|
|
231
|
+
break
|
|
232
|
+
except:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Auto-detect password field
|
|
236
|
+
password_locator = None
|
|
237
|
+
password_selectors = [
|
|
238
|
+
'input[autocomplete="current-password"]',
|
|
239
|
+
'input[autocomplete="new-password"]',
|
|
240
|
+
'input[id="password"]',
|
|
241
|
+
'input[name="password"]',
|
|
242
|
+
'input[type="password"]',
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
for selector in password_selectors:
|
|
246
|
+
try:
|
|
247
|
+
elements = await login_page.locator(selector).all()
|
|
248
|
+
for element in elements:
|
|
249
|
+
if await element.is_visible():
|
|
250
|
+
password_locator = element
|
|
251
|
+
break
|
|
252
|
+
if password_locator:
|
|
253
|
+
break
|
|
254
|
+
except:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Fill the login form
|
|
258
|
+
if username_locator:
|
|
259
|
+
await username_locator.fill(username)
|
|
260
|
+
else:
|
|
261
|
+
print(f"Warning: Could not find username field for {url}")
|
|
262
|
+
|
|
263
|
+
if password_locator:
|
|
264
|
+
await password_locator.fill(password)
|
|
265
|
+
# Submit the form by pressing Enter on password field
|
|
266
|
+
await password_locator.press("Enter")
|
|
267
|
+
|
|
268
|
+
# Wait for navigation after login
|
|
269
|
+
try:
|
|
270
|
+
await login_page.wait_for_load_state("domcontentloaded", timeout=30000)
|
|
271
|
+
except:
|
|
272
|
+
print("Login navigation timeout, continuing...")
|
|
273
|
+
else:
|
|
274
|
+
print(f"Warning: Could not find password field for {url}")
|
|
275
|
+
|
|
276
|
+
# Close the login tab - the session cookies are preserved in the context
|
|
277
|
+
await login_page.close()
|
|
278
|
+
print(f"Pre-login completed for {username} at {url}")
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
print(f"Error during pre-login for {login_info.get('url', 'unknown')}: {e}")
|
|
282
|
+
# Try to close the page if it was created
|
|
283
|
+
try:
|
|
284
|
+
await login_page.close()
|
|
285
|
+
except:
|
|
286
|
+
pass
|
|
287
|
+
raise RuntimeError(f"Error during pre-login: {e}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def init_browser():
|
|
291
|
+
"""Initialize browser on startup"""
|
|
292
|
+
global _playwright, _browser, _context, _pages, _current_page_index
|
|
293
|
+
|
|
294
|
+
print("Initializing browser...")
|
|
295
|
+
_playwright = await async_playwright().start()
|
|
296
|
+
_browser = await _playwright.chromium.launch(headless=True)
|
|
297
|
+
_context = await _browser.new_context(viewport={"width": 1280, "height": 960})
|
|
298
|
+
_context.set_default_timeout(20000) # 20 seconds (in milliseconds)
|
|
299
|
+
|
|
300
|
+
# Process pre-logins before setting up the main page
|
|
301
|
+
await process_pre_logins()
|
|
302
|
+
|
|
303
|
+
page = await _context.new_page()
|
|
304
|
+
await setup_page_listeners(page)
|
|
305
|
+
_pages = [page]
|
|
306
|
+
_current_page_index = 0
|
|
307
|
+
print("Browser initialized successfully")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def cleanup_browser():
|
|
311
|
+
"""Cleanup browser on server shutdown"""
|
|
312
|
+
global _browser, _context, _pages, _playwright
|
|
313
|
+
|
|
314
|
+
print("Shutting down browser...")
|
|
315
|
+
if _pages:
|
|
316
|
+
for page in _pages:
|
|
317
|
+
await page.close()
|
|
318
|
+
_pages = []
|
|
319
|
+
|
|
320
|
+
if _context:
|
|
321
|
+
await _context.close()
|
|
322
|
+
|
|
323
|
+
if _browser:
|
|
324
|
+
await _browser.close()
|
|
325
|
+
|
|
326
|
+
if _playwright:
|
|
327
|
+
await _playwright.stop()
|
|
328
|
+
print("Browser shutdown complete")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def ensure_browser() -> Page:
|
|
332
|
+
"""Ensure browser is launched and ready"""
|
|
333
|
+
global _pages, _current_page_index
|
|
334
|
+
|
|
335
|
+
if not _pages:
|
|
336
|
+
raise RuntimeError("Browser not initialized")
|
|
337
|
+
|
|
338
|
+
if _current_page_index >= len(_pages):
|
|
339
|
+
raise RuntimeError("No active page")
|
|
340
|
+
return _pages[_current_page_index]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def add_to_history(url: str, title: str) -> None:
|
|
344
|
+
"""Add a page visit to browsing history"""
|
|
345
|
+
global _browsing_history
|
|
346
|
+
from datetime import datetime
|
|
347
|
+
|
|
348
|
+
timestamp = datetime.now().isoformat()
|
|
349
|
+
|
|
350
|
+
# If URL already exists, update it and move to end (O(1) operation)
|
|
351
|
+
if url in _browsing_history:
|
|
352
|
+
_browsing_history[url] = {"url": url, "title": title, "timestamp": timestamp}
|
|
353
|
+
_browsing_history.move_to_end(url)
|
|
354
|
+
else:
|
|
355
|
+
# Add new entry to the end (most recent)
|
|
356
|
+
_browsing_history[url] = {"url": url, "title": title, "timestamp": timestamp}
|
|
357
|
+
|
|
358
|
+
save_current_data()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def setup_page_listeners(page: Page) -> None:
|
|
362
|
+
"""Setup listeners for a page to track navigation events"""
|
|
363
|
+
|
|
364
|
+
def on_load():
|
|
365
|
+
# Track page loads - note: event handlers in Playwright are sync
|
|
366
|
+
try:
|
|
367
|
+
# Schedule async work to be done
|
|
368
|
+
asyncio.create_task(_add_to_history_from_page(page))
|
|
369
|
+
except Exception as e:
|
|
370
|
+
# Silently ignore errors in listener
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
page.on("load", on_load)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def _add_to_history_from_page(page: Page) -> None:
|
|
377
|
+
"""Helper to add current page to history (called from sync event handler)"""
|
|
378
|
+
try:
|
|
379
|
+
browser_url = page.url
|
|
380
|
+
# Translate browser URL (mapped) to agent URL (original) before storing
|
|
381
|
+
url = translate_url_for_agent(browser_url)
|
|
382
|
+
title = await page.title()
|
|
383
|
+
await add_to_history(url, title)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def mask_password_values_in_snapshot(page: Page, snapshot_text: str) -> str:
|
|
389
|
+
"""Mask password input field values in ARIA snapshot"""
|
|
390
|
+
try:
|
|
391
|
+
# Find all password input fields and their values
|
|
392
|
+
password_inputs = await page.locator('input[type="password"]').all()
|
|
393
|
+
|
|
394
|
+
for password_input in password_inputs:
|
|
395
|
+
try:
|
|
396
|
+
# Get the value of the password field
|
|
397
|
+
value = await password_input.input_value()
|
|
398
|
+
|
|
399
|
+
if value:
|
|
400
|
+
# Mask the password value in the snapshot text
|
|
401
|
+
# The ARIA snapshot shows values as children of textbox elements
|
|
402
|
+
# We need to replace the actual password value with asterisks
|
|
403
|
+
masked_value = "*" * len(value)
|
|
404
|
+
|
|
405
|
+
# Replace all occurrences of the password value in the snapshot
|
|
406
|
+
snapshot_text = snapshot_text.replace(value, masked_value)
|
|
407
|
+
except Exception:
|
|
408
|
+
# Skip if we can't get the value for this specific input
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
except Exception:
|
|
412
|
+
# If we can't find password inputs, return snapshot as-is
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
return snapshot_text
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def hardcode_fix_for_ecommerce_review(snapshot_text: str) -> str:
|
|
419
|
+
"""FIXME"""
|
|
420
|
+
try:
|
|
421
|
+
import re
|
|
422
|
+
|
|
423
|
+
pattern = re.compile(
|
|
424
|
+
r'(- radio "Rating.{0,50}\d stars?".{0,50}) \[ref=e\d+\](\s+- generic "\d stars?") \[ref=e\d+\]( \[cursor=pointer\]:)',
|
|
425
|
+
re.S,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
cleaned = re.sub(pattern, r"\1\2\3", snapshot_text)
|
|
429
|
+
return cleaned
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
return snapshot_text
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def get_page_snapshot() -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
437
|
+
"""Get current page snapshot including URL, title, and ARIA snapshot, and optionally a screenshot"""
|
|
438
|
+
global _include_screenshot_in_snapshot
|
|
439
|
+
page = await ensure_browser()
|
|
440
|
+
browser_url = page.url
|
|
441
|
+
# Translate browser URL (mapped) to agent URL (original)
|
|
442
|
+
url = translate_url_for_agent(browser_url)
|
|
443
|
+
title = await page.title()
|
|
444
|
+
|
|
445
|
+
# Get ARIA snapshot in YAML format (matching TypeScript implementation)
|
|
446
|
+
try:
|
|
447
|
+
# snapshot_text = await page.locator("body").aria_snapshot()
|
|
448
|
+
snapshot_text = await page._impl_obj._channel.send("snapshotForAI", None, {"timeout": 20000})
|
|
449
|
+
if not snapshot_text:
|
|
450
|
+
snapshot_text = "(empty)"
|
|
451
|
+
else:
|
|
452
|
+
# Translate URLs in the snapshot from mapped to original
|
|
453
|
+
snapshot_text = translate_urls_in_snapshot(snapshot_text)
|
|
454
|
+
# Mask password values in the snapshot
|
|
455
|
+
# snapshot_text = await mask_password_values_in_snapshot(page, snapshot_text)
|
|
456
|
+
# Hardcoded fix for ecommerce review page
|
|
457
|
+
snapshot_text = hardcode_fix_for_ecommerce_review(snapshot_text)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
snapshot_text = f"(unable to capture snapshot: {e})"
|
|
460
|
+
|
|
461
|
+
text_result = f"""### Page state
|
|
462
|
+
- Page URL: {url}
|
|
463
|
+
- Page Title: {title}
|
|
464
|
+
- Page Snapshot:
|
|
465
|
+
```yaml
|
|
466
|
+
{snapshot_text}
|
|
467
|
+
```
|
|
468
|
+
To reference an element in the page snapshot, set the `ref` parameter to `aria-ref={{ref_id}}` without brackets. For example, use ref="aria-ref=e2", not ref="[aria-ref=e2]". Traditional CSS selector syntax is also supported, but using `aria-ref` is preferred."""
|
|
469
|
+
|
|
470
|
+
# If screenshot should be included, return both text and image
|
|
471
|
+
if _include_screenshot_in_snapshot:
|
|
472
|
+
try:
|
|
473
|
+
# Take a screenshot
|
|
474
|
+
screenshot_bytes = await page.screenshot(type="png", full_page=False)
|
|
475
|
+
|
|
476
|
+
# Convert bytes to PIL Image, then to ImageContent
|
|
477
|
+
image = PILImage.open(io.BytesIO(screenshot_bytes))
|
|
478
|
+
|
|
479
|
+
# Scale image if needed (max 1568x1568, 1.15 megapixels)
|
|
480
|
+
pixels = image.width * image.height
|
|
481
|
+
max_pixels = 1.15 * 1024 * 1024
|
|
482
|
+
max_dimension = 1568
|
|
483
|
+
|
|
484
|
+
shrink = min(max_dimension / image.width, max_dimension / image.height, (max_pixels / pixels) ** 0.5)
|
|
485
|
+
if shrink < 1:
|
|
486
|
+
new_width = int(image.width * shrink)
|
|
487
|
+
new_height = int(image.height * shrink)
|
|
488
|
+
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
|
489
|
+
|
|
490
|
+
# Convert to ImageContent
|
|
491
|
+
buffer = io.BytesIO()
|
|
492
|
+
image.save(buffer, format="PNG")
|
|
493
|
+
img_bytes = buffer.getvalue()
|
|
494
|
+
img_obj = Image(data=img_bytes, format="png")
|
|
495
|
+
image_content = img_obj.to_image_content()
|
|
496
|
+
|
|
497
|
+
# Return both text and image
|
|
498
|
+
return [TextContent(type="text", text=text_result), image_content]
|
|
499
|
+
except Exception as e:
|
|
500
|
+
# If screenshot fails, just return text with error message
|
|
501
|
+
return text_result + f"\n\n(Failed to capture screenshot: {e})"
|
|
502
|
+
|
|
503
|
+
return text_result
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def get_tabs_info() -> str:
|
|
507
|
+
"""Get formatted information about all open tabs"""
|
|
508
|
+
global _pages, _current_page_index
|
|
509
|
+
|
|
510
|
+
if len(_pages) <= 1:
|
|
511
|
+
return "" # Don't show tabs if only one tab
|
|
512
|
+
|
|
513
|
+
tab_info = []
|
|
514
|
+
for i, page in enumerate(_pages):
|
|
515
|
+
try:
|
|
516
|
+
title = await page.title()
|
|
517
|
+
browser_url = page.url
|
|
518
|
+
# Translate browser URL (mapped) to agent URL (original)
|
|
519
|
+
url = translate_url_for_agent(browser_url)
|
|
520
|
+
current = " (current)" if i == _current_page_index else ""
|
|
521
|
+
tab_info.append(f"- {i}:{current} [{title}] ({url})")
|
|
522
|
+
except:
|
|
523
|
+
tab_info.append(f"- {i}: (unable to get info)")
|
|
524
|
+
|
|
525
|
+
return "### Open tabs\n" + "\n".join(tab_info)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def format_response_with_snapshot(
|
|
529
|
+
result: str, include_snapshot: bool = True, include_tabs: bool = False
|
|
530
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
531
|
+
"""Format response with result, optional tabs, and optional snapshot"""
|
|
532
|
+
parts = []
|
|
533
|
+
|
|
534
|
+
if result:
|
|
535
|
+
parts.append(f"### Result\n{result}\n")
|
|
536
|
+
|
|
537
|
+
# Add tabs info if requested or if multiple tabs exist
|
|
538
|
+
if include_tabs or (include_snapshot and len(_pages) > 1):
|
|
539
|
+
try:
|
|
540
|
+
tabs = await get_tabs_info()
|
|
541
|
+
if tabs:
|
|
542
|
+
parts.append(f"\n{tabs}\n")
|
|
543
|
+
except:
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
if include_snapshot:
|
|
547
|
+
try:
|
|
548
|
+
snapshot = await get_page_snapshot()
|
|
549
|
+
# If snapshot includes images (it's a list), combine text parts and append image
|
|
550
|
+
if isinstance(snapshot, list):
|
|
551
|
+
# snapshot is [TextContent, ImageContent]
|
|
552
|
+
text_parts = "\n".join(parts)
|
|
553
|
+
if text_parts:
|
|
554
|
+
# Prepend the result/tabs text to the snapshot text
|
|
555
|
+
snapshot[0].text = text_parts + "\n" + snapshot[0].text
|
|
556
|
+
return snapshot
|
|
557
|
+
else:
|
|
558
|
+
# snapshot is just a string
|
|
559
|
+
parts.append(f"\n{snapshot}")
|
|
560
|
+
except Exception as e:
|
|
561
|
+
parts.append(f"\n### Page state\n(Unable to capture snapshot: {e})")
|
|
562
|
+
|
|
563
|
+
return "\n".join(parts)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@mcp.tool()
|
|
567
|
+
async def browser_navigate(
|
|
568
|
+
url: Annotated[str, "The URL to navigate to"],
|
|
569
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
570
|
+
"""Navigate to a URL"""
|
|
571
|
+
page = await ensure_browser()
|
|
572
|
+
# Translate agent's URL (original) to browser URL (mapped)
|
|
573
|
+
browser_url = translate_url_for_browser(url)
|
|
574
|
+
await page.goto(browser_url, wait_until="networkidle")
|
|
575
|
+
title = await page.title()
|
|
576
|
+
# Show agent the original URL they requested
|
|
577
|
+
result = f"Navigated to '{title}' at {url}"
|
|
578
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@mcp.tool()
|
|
582
|
+
async def browser_navigate_back() -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
583
|
+
"""Go back to the previous page"""
|
|
584
|
+
page = await ensure_browser()
|
|
585
|
+
await page.go_back(wait_until="networkidle")
|
|
586
|
+
title = await page.title()
|
|
587
|
+
result = f"Navigated back to '{title}'"
|
|
588
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@mcp.tool()
|
|
592
|
+
async def browser_get_history(
|
|
593
|
+
limit: Annotated[Optional[int], "Optional limit on number of history entries to return (most recent first)"] = 50,
|
|
594
|
+
) -> str:
|
|
595
|
+
"""Get the browsing history"""
|
|
596
|
+
global _browsing_history
|
|
597
|
+
|
|
598
|
+
if not _browsing_history:
|
|
599
|
+
return "### Browsing History\nNo browsing history yet"
|
|
600
|
+
|
|
601
|
+
# Get entries in reverse order (most recent first)
|
|
602
|
+
history_entries = list(_browsing_history.items())[::-1]
|
|
603
|
+
|
|
604
|
+
if limit and limit > 0:
|
|
605
|
+
history_entries = history_entries[:limit]
|
|
606
|
+
|
|
607
|
+
history_lines = ["### Browsing History"]
|
|
608
|
+
for i, (url, entry) in enumerate(history_entries, 1):
|
|
609
|
+
timestamp = entry.get("timestamp", "unknown")
|
|
610
|
+
title = entry.get("title", "untitled")
|
|
611
|
+
history_lines.append(f"{i}. [{title}]({url}) - {timestamp}")
|
|
612
|
+
|
|
613
|
+
return "\n".join(history_lines)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@mcp.tool()
|
|
617
|
+
async def browser_open_history(
|
|
618
|
+
history_id: Annotated[int, "The history item ID (number shown in browser_get_history)"],
|
|
619
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
620
|
+
"""Open a URL from browsing history by its ID"""
|
|
621
|
+
global _browsing_history
|
|
622
|
+
|
|
623
|
+
if not _browsing_history:
|
|
624
|
+
return "### Result\nNo browsing history available"
|
|
625
|
+
|
|
626
|
+
# Get entries in reverse order (most recent first) to match the displayed IDs
|
|
627
|
+
history_entries = list(_browsing_history.items())[::-1]
|
|
628
|
+
|
|
629
|
+
if history_id < 1 or history_id > len(history_entries):
|
|
630
|
+
return f"### Result\nInvalid history ID. Please use a number between 1 and {len(history_entries)}"
|
|
631
|
+
|
|
632
|
+
# Get the URL from the history entry (ID is 1-based)
|
|
633
|
+
url, entry = history_entries[history_id - 1]
|
|
634
|
+
title = entry.get("title", "untitled")
|
|
635
|
+
|
|
636
|
+
# Navigate to the URL - translate to mapped URL for actual navigation
|
|
637
|
+
page = await ensure_browser()
|
|
638
|
+
browser_url = translate_url_for_browser(url)
|
|
639
|
+
await page.goto(browser_url)
|
|
640
|
+
|
|
641
|
+
# Show agent the original URL
|
|
642
|
+
result = f"Opened history item #{history_id}: '{title}' at {url}"
|
|
643
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@mcp.tool()
|
|
647
|
+
async def browser_snapshot() -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
648
|
+
"""Capture accessibility snapshot of the current page, this is better than screenshot"""
|
|
649
|
+
return await get_page_snapshot()
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@mcp.tool()
|
|
653
|
+
async def browser_click(
|
|
654
|
+
ref: Annotated[str, "Exact target element reference from the page snapshot"],
|
|
655
|
+
force: Annotated[bool, "Whether to force the click even if element is not visible"] = False,
|
|
656
|
+
doubleClick: Annotated[bool, "Whether to perform a double click instead of a single click"] = False,
|
|
657
|
+
button: Annotated[str, "Button to click, defaults to left"] = "left",
|
|
658
|
+
modifiers: Annotated[Optional[List[str]], "Modifier keys to press"] = None,
|
|
659
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
660
|
+
"""Perform click on a web page"""
|
|
661
|
+
async with _lock:
|
|
662
|
+
page = await ensure_browser()
|
|
663
|
+
original_ref = ref
|
|
664
|
+
ref = translate_ref_for_browser(ref)
|
|
665
|
+
elements = await page.locator(ref).all()
|
|
666
|
+
locator = None
|
|
667
|
+
for element in elements:
|
|
668
|
+
if await element.is_visible():
|
|
669
|
+
locator = element
|
|
670
|
+
break
|
|
671
|
+
if not locator:
|
|
672
|
+
locator = page.locator(ref).first
|
|
673
|
+
# Best-effort scroll. Some eBay elements intermittently time out here even
|
|
674
|
+
# though a direct or forced click still succeeds.
|
|
675
|
+
try:
|
|
676
|
+
await locator.scroll_into_view_if_needed(timeout=3000)
|
|
677
|
+
except PlaywrightTimeoutError:
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
click_options = {}
|
|
681
|
+
if force:
|
|
682
|
+
click_options["force"] = True
|
|
683
|
+
if button != "left":
|
|
684
|
+
click_options["button"] = button
|
|
685
|
+
if modifiers:
|
|
686
|
+
click_options["modifiers"] = modifiers
|
|
687
|
+
|
|
688
|
+
if doubleClick:
|
|
689
|
+
try:
|
|
690
|
+
await locator.dblclick(**click_options)
|
|
691
|
+
except PlaywrightTimeoutError as exc:
|
|
692
|
+
if force or "intercepts pointer events" not in str(exc).lower():
|
|
693
|
+
raise
|
|
694
|
+
retry_options = dict(click_options)
|
|
695
|
+
retry_options["force"] = True
|
|
696
|
+
await locator.dblclick(**retry_options)
|
|
697
|
+
result = f"Double-clicked element: {original_ref}"
|
|
698
|
+
else:
|
|
699
|
+
try:
|
|
700
|
+
await locator.click(**click_options)
|
|
701
|
+
except PlaywrightTimeoutError as exc:
|
|
702
|
+
if force or "intercepts pointer events" not in str(exc).lower():
|
|
703
|
+
raise
|
|
704
|
+
retry_options = dict(click_options)
|
|
705
|
+
retry_options["force"] = True
|
|
706
|
+
await locator.click(**retry_options)
|
|
707
|
+
result = f"Clicked element: {original_ref}"
|
|
708
|
+
|
|
709
|
+
# Wait for page to settle after click (network idle or small delay)
|
|
710
|
+
try:
|
|
711
|
+
await asyncio.sleep(2.0)
|
|
712
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
713
|
+
except:
|
|
714
|
+
# If networkidle times out, just wait a bit for DOM updates
|
|
715
|
+
await asyncio.sleep(0.5)
|
|
716
|
+
|
|
717
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@mcp.tool()
|
|
721
|
+
async def browser_type(
|
|
722
|
+
ref: Annotated[str, "Exact target element reference from the page snapshot"],
|
|
723
|
+
text: Annotated[str, "Text to type into the element"],
|
|
724
|
+
submit: Annotated[bool, "Whether to submit entered text (press Enter after)"] = False,
|
|
725
|
+
slowly: Annotated[bool, "Whether to type one character at a time"] = False,
|
|
726
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
727
|
+
"""Type text into editable element"""
|
|
728
|
+
async with _lock:
|
|
729
|
+
page = await ensure_browser()
|
|
730
|
+
original_ref = ref
|
|
731
|
+
ref = translate_ref_for_browser(ref)
|
|
732
|
+
elements = await page.locator(ref).all()
|
|
733
|
+
locator = None
|
|
734
|
+
for element in elements:
|
|
735
|
+
if await element.is_visible():
|
|
736
|
+
locator = element
|
|
737
|
+
break
|
|
738
|
+
if not locator:
|
|
739
|
+
locator = page.locator(ref).first
|
|
740
|
+
await locator.scroll_into_view_if_needed()
|
|
741
|
+
|
|
742
|
+
if slowly:
|
|
743
|
+
await locator.press_sequentially(text, delay=100)
|
|
744
|
+
else:
|
|
745
|
+
await locator.fill(text)
|
|
746
|
+
|
|
747
|
+
if submit:
|
|
748
|
+
await locator.press("Enter")
|
|
749
|
+
result = f"Typed text into '{original_ref}' and submitted"
|
|
750
|
+
else:
|
|
751
|
+
result = f"Typed text into '{original_ref}'"
|
|
752
|
+
|
|
753
|
+
# Wait for page to settle after typing
|
|
754
|
+
try:
|
|
755
|
+
await asyncio.sleep(0.5)
|
|
756
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
757
|
+
except:
|
|
758
|
+
await asyncio.sleep(0.5)
|
|
759
|
+
|
|
760
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@mcp.tool()
|
|
764
|
+
async def browser_press_key(
|
|
765
|
+
key: Annotated[str, "Name of the key to press or a character to generate, such as ArrowLeft or a"],
|
|
766
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
767
|
+
"""Press a key on the keyboard"""
|
|
768
|
+
page = await ensure_browser()
|
|
769
|
+
await page.keyboard.press(key)
|
|
770
|
+
result = f"Pressed key: {key}"
|
|
771
|
+
|
|
772
|
+
# Wait for page to settle after key press
|
|
773
|
+
try:
|
|
774
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
775
|
+
except:
|
|
776
|
+
await asyncio.sleep(0.5)
|
|
777
|
+
|
|
778
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@mcp.tool()
|
|
782
|
+
async def browser_hover(
|
|
783
|
+
ref: Annotated[str, "Exact target element reference from the page snapshot"],
|
|
784
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
785
|
+
"""Hover over element on page"""
|
|
786
|
+
page = await ensure_browser()
|
|
787
|
+
original_ref = ref
|
|
788
|
+
ref = translate_ref_for_browser(ref)
|
|
789
|
+
elements = await page.locator(ref).all()
|
|
790
|
+
locator = None
|
|
791
|
+
for element in elements:
|
|
792
|
+
if await element.is_visible():
|
|
793
|
+
locator = element
|
|
794
|
+
break
|
|
795
|
+
if not locator:
|
|
796
|
+
locator = page.locator(ref).first
|
|
797
|
+
await locator.scroll_into_view_if_needed()
|
|
798
|
+
await locator.hover()
|
|
799
|
+
result = f"Hovered over element: {original_ref}"
|
|
800
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@mcp.tool()
|
|
804
|
+
async def browser_drag(
|
|
805
|
+
startRef: Annotated[str, "Exact source element reference from the page snapshot"],
|
|
806
|
+
endRef: Annotated[str, "Exact target element reference from the page snapshot"],
|
|
807
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
808
|
+
"""Perform drag and drop between two elements"""
|
|
809
|
+
page = await ensure_browser()
|
|
810
|
+
original_startRef = startRef
|
|
811
|
+
original_endRef = endRef
|
|
812
|
+
startRef = translate_ref_for_browser(startRef)
|
|
813
|
+
endRef = translate_ref_for_browser(endRef)
|
|
814
|
+
|
|
815
|
+
# Get the source and target elements
|
|
816
|
+
start_elements = await page.locator(startRef).all()
|
|
817
|
+
source = None
|
|
818
|
+
for element in start_elements:
|
|
819
|
+
if await element.is_visible():
|
|
820
|
+
source = element
|
|
821
|
+
break
|
|
822
|
+
if not source:
|
|
823
|
+
source = page.locator(startRef).first
|
|
824
|
+
|
|
825
|
+
end_elements = await page.locator(endRef).all()
|
|
826
|
+
target = None
|
|
827
|
+
for element in end_elements:
|
|
828
|
+
if await element.is_visible():
|
|
829
|
+
target = element
|
|
830
|
+
break
|
|
831
|
+
if not target:
|
|
832
|
+
target = page.locator(endRef).first
|
|
833
|
+
|
|
834
|
+
await source.scroll_into_view_if_needed()
|
|
835
|
+
await target.scroll_into_view_if_needed()
|
|
836
|
+
|
|
837
|
+
# Perform drag and drop
|
|
838
|
+
await source.drag_to(target)
|
|
839
|
+
|
|
840
|
+
result = f"Dragged from {original_startRef} to {original_endRef}"
|
|
841
|
+
|
|
842
|
+
# Wait for page to settle after drag
|
|
843
|
+
try:
|
|
844
|
+
await asyncio.sleep(0.5)
|
|
845
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
846
|
+
except:
|
|
847
|
+
await asyncio.sleep(0.5)
|
|
848
|
+
|
|
849
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@mcp.tool()
|
|
853
|
+
async def browser_select_option(
|
|
854
|
+
ref: Annotated[str, "Exact target element reference from the page snapshot"],
|
|
855
|
+
values: Annotated[List[str], "Array of values to select in the dropdown"],
|
|
856
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
857
|
+
"""Select an option in a dropdown"""
|
|
858
|
+
page = await ensure_browser()
|
|
859
|
+
original_ref = ref
|
|
860
|
+
ref = translate_ref_for_browser(ref)
|
|
861
|
+
elements = await page.locator(ref).all()
|
|
862
|
+
locator = None
|
|
863
|
+
for element in elements:
|
|
864
|
+
if await element.is_visible():
|
|
865
|
+
locator = element
|
|
866
|
+
break
|
|
867
|
+
if not locator:
|
|
868
|
+
locator = page.locator(ref).first
|
|
869
|
+
await locator.scroll_into_view_if_needed()
|
|
870
|
+
await locator.select_option(values)
|
|
871
|
+
result = f"Selected options {values} in dropdown: {original_ref}"
|
|
872
|
+
|
|
873
|
+
# Wait for page to settle after selection
|
|
874
|
+
try:
|
|
875
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
876
|
+
except:
|
|
877
|
+
await asyncio.sleep(0.5)
|
|
878
|
+
|
|
879
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# @mcp.tool()
|
|
883
|
+
# async def browser_fill_form(
|
|
884
|
+
# fields: Annotated[
|
|
885
|
+
# List[Dict[str, str]], "Array of field objects with 'name', 'type', 'ref', and 'value' properties"
|
|
886
|
+
# ],
|
|
887
|
+
# ) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
888
|
+
# """Fill multiple form fields"""
|
|
889
|
+
# page = await ensure_browser()
|
|
890
|
+
|
|
891
|
+
# results = []
|
|
892
|
+
# for field in fields:
|
|
893
|
+
# field_type = field.get("type", "textbox")
|
|
894
|
+
# original_ref = field["ref"]
|
|
895
|
+
# ref = translate_ref_for_browser(field["ref"])
|
|
896
|
+
# value = field["value"]
|
|
897
|
+
# name = field.get("name", original_ref)
|
|
898
|
+
|
|
899
|
+
# elements = await page.locator(ref).all()
|
|
900
|
+
# locator = None
|
|
901
|
+
# for element in elements:
|
|
902
|
+
# if await element.is_visible():
|
|
903
|
+
# locator = element
|
|
904
|
+
# break
|
|
905
|
+
# if not locator:
|
|
906
|
+
# locator = page.locator(ref).first
|
|
907
|
+
|
|
908
|
+
# if field_type in ["textbox", "slider"]:
|
|
909
|
+
# await locator.fill(value)
|
|
910
|
+
# results.append(f"Filled {name}")
|
|
911
|
+
# elif field_type in ["checkbox", "radio"]:
|
|
912
|
+
# checked = value.lower() == "true"
|
|
913
|
+
# await locator.set_checked(checked)
|
|
914
|
+
# results.append(f"Set {name} to {checked}")
|
|
915
|
+
# elif field_type == "combobox":
|
|
916
|
+
# await locator.select_option(label=value)
|
|
917
|
+
# results.append(f"Selected '{value}' in {name}")
|
|
918
|
+
|
|
919
|
+
# result = f"Filled form fields: {', '.join(results)}"
|
|
920
|
+
# return await format_response_with_snapshot(result, include_snapshot=False)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
@mcp.tool()
|
|
924
|
+
async def browser_wait_for(
|
|
925
|
+
time: Annotated[Optional[float], "The time to wait in seconds"] = None,
|
|
926
|
+
text: Annotated[Optional[str], "The text to wait for"] = None,
|
|
927
|
+
textGone: Annotated[Optional[str], "The text to wait for to disappear"] = None,
|
|
928
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
929
|
+
"""Wait for text to appear or disappear or a specified time to pass"""
|
|
930
|
+
page = await ensure_browser()
|
|
931
|
+
|
|
932
|
+
if not time and not text and not textGone:
|
|
933
|
+
raise ValueError("Either time, text or textGone must be provided")
|
|
934
|
+
|
|
935
|
+
results = []
|
|
936
|
+
|
|
937
|
+
if time:
|
|
938
|
+
await asyncio.sleep(min(30.0, time))
|
|
939
|
+
results.append(f"waited {time} seconds")
|
|
940
|
+
|
|
941
|
+
if textGone:
|
|
942
|
+
await page.get_by_text(textGone).first.wait_for(state="hidden", timeout=20000)
|
|
943
|
+
results.append(f"text '{textGone}' disappeared")
|
|
944
|
+
|
|
945
|
+
if text:
|
|
946
|
+
await page.get_by_text(text).first.wait_for(state="visible", timeout=20000)
|
|
947
|
+
results.append(f"text '{text}' appeared")
|
|
948
|
+
|
|
949
|
+
result = f"Wait completed: {', '.join(results)}"
|
|
950
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
@mcp.tool()
|
|
954
|
+
async def browser_take_screenshot(
|
|
955
|
+
type: Annotated[str, "Image format for the screenshot (png or jpeg)"] = "png",
|
|
956
|
+
ref: Annotated[
|
|
957
|
+
Optional[str], "Exact target element reference from the page snapshot (for element screenshot)"
|
|
958
|
+
] = None,
|
|
959
|
+
fullPage: Annotated[bool, "When true, takes a screenshot of the full scrollable page"] = False,
|
|
960
|
+
) -> List[Union[TextContent, ImageContent]]:
|
|
961
|
+
"""Take a screenshot of the current page"""
|
|
962
|
+
page = await ensure_browser()
|
|
963
|
+
|
|
964
|
+
if fullPage and ref:
|
|
965
|
+
raise ValueError("fullPage cannot be used with element screenshots")
|
|
966
|
+
|
|
967
|
+
screenshot_options = {
|
|
968
|
+
"type": type,
|
|
969
|
+
"full_page": fullPage,
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if type == "jpeg":
|
|
973
|
+
screenshot_options["quality"] = 90
|
|
974
|
+
|
|
975
|
+
if ref:
|
|
976
|
+
# Element screenshot
|
|
977
|
+
ref = translate_ref_for_browser(ref)
|
|
978
|
+
element = page.locator(ref)
|
|
979
|
+
screenshot_bytes = await element.screenshot(**screenshot_options)
|
|
980
|
+
target_desc = f"element {ref}"
|
|
981
|
+
else:
|
|
982
|
+
# Page screenshot
|
|
983
|
+
screenshot_bytes = await page.screenshot(**screenshot_options)
|
|
984
|
+
target_desc = "full page" if fullPage else "viewport"
|
|
985
|
+
|
|
986
|
+
text_result = f"Screenshot of {target_desc}"
|
|
987
|
+
|
|
988
|
+
# Convert bytes to PIL Image, then to ImageContent
|
|
989
|
+
image = PILImage.open(io.BytesIO(screenshot_bytes))
|
|
990
|
+
|
|
991
|
+
# Scale image if needed (max 1568x1568, 1.15 megapixels)
|
|
992
|
+
pixels = image.width * image.height
|
|
993
|
+
max_pixels = 1.15 * 1024 * 1024
|
|
994
|
+
max_dimension = 1568
|
|
995
|
+
|
|
996
|
+
shrink = min(max_dimension / image.width, max_dimension / image.height, (max_pixels / pixels) ** 0.5)
|
|
997
|
+
if shrink < 1:
|
|
998
|
+
new_width = int(image.width * shrink)
|
|
999
|
+
new_height = int(image.height * shrink)
|
|
1000
|
+
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
|
1001
|
+
|
|
1002
|
+
# Convert to ImageContent
|
|
1003
|
+
buffer = io.BytesIO()
|
|
1004
|
+
image.save(buffer, format=type.upper())
|
|
1005
|
+
img_bytes = buffer.getvalue()
|
|
1006
|
+
img_obj = Image(data=img_bytes, format=type)
|
|
1007
|
+
image_content = img_obj.to_image_content()
|
|
1008
|
+
|
|
1009
|
+
# Return both text and image
|
|
1010
|
+
return [TextContent(type="text", text=text_result), image_content]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@mcp.tool()
|
|
1014
|
+
async def browser_resize(
|
|
1015
|
+
width: Annotated[int, "Width of the browser window"], height: Annotated[int, "Height of the browser window"]
|
|
1016
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1017
|
+
"""Resize the browser window"""
|
|
1018
|
+
page = await ensure_browser()
|
|
1019
|
+
await page.set_viewport_size({"width": width, "height": height})
|
|
1020
|
+
result = f"Resized browser window to {width}x{height}"
|
|
1021
|
+
return await format_response_with_snapshot(result, include_snapshot=False)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
@mcp.tool()
|
|
1025
|
+
async def browser_close() -> str:
|
|
1026
|
+
"""Close the page"""
|
|
1027
|
+
global _browser, _context, _pages, _playwright, _current_page_index
|
|
1028
|
+
|
|
1029
|
+
if _pages:
|
|
1030
|
+
# Close all pages
|
|
1031
|
+
for page in _pages:
|
|
1032
|
+
await page.close()
|
|
1033
|
+
_pages = []
|
|
1034
|
+
_current_page_index = 0
|
|
1035
|
+
|
|
1036
|
+
if _context:
|
|
1037
|
+
await _context.close()
|
|
1038
|
+
_context = None
|
|
1039
|
+
|
|
1040
|
+
if _browser:
|
|
1041
|
+
await _browser.close()
|
|
1042
|
+
_browser = None
|
|
1043
|
+
|
|
1044
|
+
if _playwright:
|
|
1045
|
+
await _playwright.stop()
|
|
1046
|
+
_playwright = None
|
|
1047
|
+
|
|
1048
|
+
return "### Result\nBrowser closed successfully"
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
@mcp.tool()
|
|
1052
|
+
async def browser_tabs(
|
|
1053
|
+
action: Annotated[str, "Operation to perform (list, new, close, select)"],
|
|
1054
|
+
index: Annotated[Optional[int], "Tab index, used for close/select"] = None,
|
|
1055
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1056
|
+
"""List, create, close, or select a browser tab"""
|
|
1057
|
+
global _pages, _current_page_index, _context
|
|
1058
|
+
|
|
1059
|
+
if action == "list":
|
|
1060
|
+
tabs_info = await get_tabs_info()
|
|
1061
|
+
if not tabs_info:
|
|
1062
|
+
# Single tab case - translate URL for agent
|
|
1063
|
+
browser_url = _pages[0].url
|
|
1064
|
+
url = translate_url_for_agent(browser_url)
|
|
1065
|
+
return "### Open tabs\n- 0: (current) [" + await _pages[0].title() + "] (" + url + ")"
|
|
1066
|
+
return tabs_info
|
|
1067
|
+
|
|
1068
|
+
elif action == "new":
|
|
1069
|
+
if not _context:
|
|
1070
|
+
raise RuntimeError("Browser context not initialized")
|
|
1071
|
+
new_page = await _context.new_page()
|
|
1072
|
+
await setup_page_listeners(new_page)
|
|
1073
|
+
new_tab_index = len(_pages)
|
|
1074
|
+
_pages.append(new_page)
|
|
1075
|
+
_current_page_index = new_tab_index
|
|
1076
|
+
result = f"Created new tab (index {_current_page_index})"
|
|
1077
|
+
# Always include tabs info when creating new tab
|
|
1078
|
+
return await format_response_with_snapshot(result, include_snapshot=True, include_tabs=True)
|
|
1079
|
+
|
|
1080
|
+
elif action == "close":
|
|
1081
|
+
if index is None:
|
|
1082
|
+
index = _current_page_index
|
|
1083
|
+
|
|
1084
|
+
if index < 0 or index >= len(_pages):
|
|
1085
|
+
raise ValueError(f"Invalid tab index: {index}")
|
|
1086
|
+
|
|
1087
|
+
page_to_close = _pages[index]
|
|
1088
|
+
await page_to_close.close()
|
|
1089
|
+
_pages.pop(index)
|
|
1090
|
+
|
|
1091
|
+
# Adjust current page index
|
|
1092
|
+
if len(_pages) == 0:
|
|
1093
|
+
# Reopen a new tab when closing the last one
|
|
1094
|
+
new_page = await _context.new_page()
|
|
1095
|
+
await setup_page_listeners(new_page)
|
|
1096
|
+
_pages.append(new_page)
|
|
1097
|
+
_current_page_index = 0
|
|
1098
|
+
elif _current_page_index >= len(_pages):
|
|
1099
|
+
_current_page_index = len(_pages) - 1
|
|
1100
|
+
|
|
1101
|
+
result = f"Closed tab {index}"
|
|
1102
|
+
# Include tabs info after closing to show remaining tabs
|
|
1103
|
+
return await format_response_with_snapshot(result, include_snapshot=True, include_tabs=True)
|
|
1104
|
+
|
|
1105
|
+
elif action == "select":
|
|
1106
|
+
if index is None:
|
|
1107
|
+
raise ValueError("Tab index is required for select action")
|
|
1108
|
+
|
|
1109
|
+
if index < 0 or index >= len(_pages):
|
|
1110
|
+
raise ValueError(f"Invalid tab index: {index}")
|
|
1111
|
+
|
|
1112
|
+
_current_page_index = index
|
|
1113
|
+
result = f"Selected tab {index}"
|
|
1114
|
+
# Include tabs info and snapshot when switching tabs
|
|
1115
|
+
return await format_response_with_snapshot(result, include_snapshot=True, include_tabs=True)
|
|
1116
|
+
|
|
1117
|
+
else:
|
|
1118
|
+
raise ValueError(f"Invalid action: {action}")
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
@mcp.tool()
|
|
1122
|
+
async def browser_mouse_move_xy(
|
|
1123
|
+
x: Annotated[int, "X coordinate"], y: Annotated[int, "Y coordinate"]
|
|
1124
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1125
|
+
"""Move mouse to a given position"""
|
|
1126
|
+
page = await ensure_browser()
|
|
1127
|
+
await page.mouse.move(x, y)
|
|
1128
|
+
result = f"Moved mouse to ({x}, {y})"
|
|
1129
|
+
return await format_response_with_snapshot(result, include_snapshot=False)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
@mcp.tool()
|
|
1133
|
+
async def browser_mouse_click_xy(
|
|
1134
|
+
x: Annotated[int, "X coordinate"], y: Annotated[int, "Y coordinate"]
|
|
1135
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1136
|
+
"""Click left mouse button at a given position"""
|
|
1137
|
+
page = await ensure_browser()
|
|
1138
|
+
await page.mouse.move(x, y)
|
|
1139
|
+
await page.mouse.down()
|
|
1140
|
+
await page.mouse.up()
|
|
1141
|
+
result = f"Clicked at ({x}, {y})"
|
|
1142
|
+
|
|
1143
|
+
# Wait for page to settle after click
|
|
1144
|
+
try:
|
|
1145
|
+
await asyncio.sleep(0.5)
|
|
1146
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
1147
|
+
except:
|
|
1148
|
+
await asyncio.sleep(0.5)
|
|
1149
|
+
|
|
1150
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@mcp.tool()
|
|
1154
|
+
async def browser_mouse_drag_xy(
|
|
1155
|
+
startX: Annotated[int, "Start X coordinate"],
|
|
1156
|
+
startY: Annotated[int, "Start Y coordinate"],
|
|
1157
|
+
endX: Annotated[int, "End X coordinate"],
|
|
1158
|
+
endY: Annotated[int, "End Y coordinate"],
|
|
1159
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1160
|
+
"""Drag left mouse button to a given position"""
|
|
1161
|
+
page = await ensure_browser()
|
|
1162
|
+
await page.mouse.move(startX, startY)
|
|
1163
|
+
await page.mouse.down()
|
|
1164
|
+
await page.mouse.move(endX, endY)
|
|
1165
|
+
await page.mouse.up()
|
|
1166
|
+
result = f"Dragged from ({startX}, {startY}) to ({endX}, {endY})"
|
|
1167
|
+
|
|
1168
|
+
# Wait for page to settle after drag
|
|
1169
|
+
try:
|
|
1170
|
+
await asyncio.sleep(0.5)
|
|
1171
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
1172
|
+
except:
|
|
1173
|
+
await asyncio.sleep(0.5)
|
|
1174
|
+
|
|
1175
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
@mcp.tool()
|
|
1179
|
+
async def browser_save_password(
|
|
1180
|
+
url_pattern: Annotated[str, "URL or URL pattern (e.g., 'example.com' or 'https://example.com/login')"],
|
|
1181
|
+
username: Annotated[str, "Username or email"],
|
|
1182
|
+
password: Annotated[str, "Password"],
|
|
1183
|
+
) -> str:
|
|
1184
|
+
"""Save login credentials for autofill"""
|
|
1185
|
+
global _saved_passwords
|
|
1186
|
+
|
|
1187
|
+
# Check if credential for this URL pattern already exists
|
|
1188
|
+
for i, cred in enumerate(_saved_passwords):
|
|
1189
|
+
if cred["url_pattern"] == url_pattern and cred["username"] == username:
|
|
1190
|
+
# Update existing credential
|
|
1191
|
+
_saved_passwords[i]["password"] = password
|
|
1192
|
+
save_current_data()
|
|
1193
|
+
return f"### Result\nUpdated saved password for {username} at {url_pattern}"
|
|
1194
|
+
|
|
1195
|
+
# Add new credential
|
|
1196
|
+
_saved_passwords.append({"url_pattern": url_pattern, "username": username, "password": password})
|
|
1197
|
+
save_current_data()
|
|
1198
|
+
return f"### Result\nSaved password for {username} at {url_pattern}"
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@mcp.tool()
|
|
1202
|
+
async def browser_list_saved_passwords() -> str:
|
|
1203
|
+
"""List all saved passwords (passwords are masked)"""
|
|
1204
|
+
global _saved_passwords
|
|
1205
|
+
|
|
1206
|
+
if not _saved_passwords:
|
|
1207
|
+
return "### Saved Passwords\nNo saved passwords"
|
|
1208
|
+
|
|
1209
|
+
lines = ["### Saved Passwords"]
|
|
1210
|
+
for i, cred in enumerate(_saved_passwords, 1):
|
|
1211
|
+
masked_password = "*" * len(cred["password"])
|
|
1212
|
+
lines.append(f"{i}. {cred['url_pattern']} - {cred['username']} (password: {masked_password})")
|
|
1213
|
+
|
|
1214
|
+
return "\n".join(lines)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
@mcp.tool()
|
|
1218
|
+
async def browser_delete_password(
|
|
1219
|
+
url_pattern: Annotated[str, "URL pattern of the password to delete"],
|
|
1220
|
+
username: Annotated[str, "Username of the password to delete"],
|
|
1221
|
+
) -> str:
|
|
1222
|
+
"""Delete a saved password"""
|
|
1223
|
+
global _saved_passwords
|
|
1224
|
+
|
|
1225
|
+
for i, cred in enumerate(_saved_passwords):
|
|
1226
|
+
if cred["url_pattern"] == url_pattern and cred["username"] == username:
|
|
1227
|
+
_saved_passwords.pop(i)
|
|
1228
|
+
save_current_data()
|
|
1229
|
+
return f"### Result\nDeleted password for {username} at {url_pattern}"
|
|
1230
|
+
|
|
1231
|
+
return f"### Result\nNo saved password found for {username} at {url_pattern}"
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@mcp.tool()
|
|
1235
|
+
async def browser_save_credit_card(
|
|
1236
|
+
name: Annotated[str, "Cardholder name"],
|
|
1237
|
+
number: Annotated[str, "Credit card number"],
|
|
1238
|
+
expiry_month: Annotated[str, "Expiry month (MM)"],
|
|
1239
|
+
expiry_year: Annotated[str, "Expiry year (YYYY or YY)"],
|
|
1240
|
+
cvv: Annotated[str, "CVV/Security code"],
|
|
1241
|
+
) -> str:
|
|
1242
|
+
"""Save credit card information for autofill"""
|
|
1243
|
+
global _saved_credit_cards
|
|
1244
|
+
|
|
1245
|
+
if len(expiry_year) == 2:
|
|
1246
|
+
expiry_year = "20" + expiry_year # Convert YY to YYYY, assuming 2000s
|
|
1247
|
+
# Check if card with same number already exists
|
|
1248
|
+
for i, card in enumerate(_saved_credit_cards):
|
|
1249
|
+
if card["number"] == number:
|
|
1250
|
+
# Update existing card
|
|
1251
|
+
_saved_credit_cards[i] = {
|
|
1252
|
+
"name": name,
|
|
1253
|
+
"number": number,
|
|
1254
|
+
"expiry_month": expiry_month,
|
|
1255
|
+
"expiry_year": expiry_year,
|
|
1256
|
+
"cvv": cvv,
|
|
1257
|
+
}
|
|
1258
|
+
save_current_data()
|
|
1259
|
+
return f"### Result\nUpdated saved credit card ending in {number[-4:]}"
|
|
1260
|
+
|
|
1261
|
+
# Add new card
|
|
1262
|
+
_saved_credit_cards.append(
|
|
1263
|
+
{"name": name, "number": number, "expiry_month": expiry_month, "expiry_year": expiry_year, "cvv": cvv}
|
|
1264
|
+
)
|
|
1265
|
+
save_current_data()
|
|
1266
|
+
return f"### Result\nSaved credit card ending in {number[-4:]}"
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
@mcp.tool()
|
|
1270
|
+
async def browser_list_saved_credit_cards() -> str:
|
|
1271
|
+
"""List all saved credit cards (numbers are partially masked)"""
|
|
1272
|
+
global _saved_credit_cards
|
|
1273
|
+
|
|
1274
|
+
if not _saved_credit_cards:
|
|
1275
|
+
return "### Saved Credit Cards\nNo saved credit cards"
|
|
1276
|
+
|
|
1277
|
+
lines = ["### Saved Credit Cards"]
|
|
1278
|
+
for i, card in enumerate(_saved_credit_cards, 1):
|
|
1279
|
+
lines.append(f"{i}. {card['name']} - {card['number']} (exp: {card['expiry_month']}/{card['expiry_year']})")
|
|
1280
|
+
|
|
1281
|
+
return "\n".join(lines)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
@mcp.tool()
|
|
1285
|
+
async def browser_delete_credit_card(number: Annotated[str, "Credit card number to delete"]) -> str:
|
|
1286
|
+
"""Delete a saved credit card"""
|
|
1287
|
+
global _saved_credit_cards
|
|
1288
|
+
|
|
1289
|
+
for i, card in enumerate(_saved_credit_cards):
|
|
1290
|
+
if card["number"] == number:
|
|
1291
|
+
_saved_credit_cards.pop(i)
|
|
1292
|
+
save_current_data()
|
|
1293
|
+
return f"### Result\nDeleted credit card ending in {number[-4:]}"
|
|
1294
|
+
|
|
1295
|
+
return f"### Result\nNo saved credit card found with that number"
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
@mcp.tool()
|
|
1299
|
+
async def browser_autofill_password(
|
|
1300
|
+
username_ref: Annotated[Optional[str], "Selector for username/email field"] = None,
|
|
1301
|
+
password_ref: Annotated[Optional[str], "Selector for password field"] = None,
|
|
1302
|
+
submit: Annotated[bool, "Whether to submit the form after filling"] = False,
|
|
1303
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1304
|
+
"""Autofill login form with saved credentials"""
|
|
1305
|
+
global _saved_passwords
|
|
1306
|
+
page = await ensure_browser()
|
|
1307
|
+
|
|
1308
|
+
if not _saved_passwords:
|
|
1309
|
+
return "### Result\nNo saved passwords available"
|
|
1310
|
+
|
|
1311
|
+
# Get current URL and translate to original URL for matching
|
|
1312
|
+
browser_url = page.url
|
|
1313
|
+
current_url = translate_url_for_agent(browser_url)
|
|
1314
|
+
|
|
1315
|
+
# Find matching credential
|
|
1316
|
+
matching_cred = None
|
|
1317
|
+
for cred in _saved_passwords:
|
|
1318
|
+
if cred["url_pattern"] in current_url or current_url in cred["url_pattern"]:
|
|
1319
|
+
matching_cred = cred
|
|
1320
|
+
break
|
|
1321
|
+
|
|
1322
|
+
if not matching_cred:
|
|
1323
|
+
# If no match found, return error with available credentials
|
|
1324
|
+
available = "\n".join([f" - {cred['username']} ({cred['url_pattern']})" for cred in _saved_passwords])
|
|
1325
|
+
return f"### Result\nNo saved password matches the current URL ({current_url}).\n\nAvailable saved passwords:\n{available}\n\nPlease save a password for this URL pattern or use browser_save_password to add one."
|
|
1326
|
+
|
|
1327
|
+
results = []
|
|
1328
|
+
|
|
1329
|
+
# Auto-detect fields if not provided
|
|
1330
|
+
username_locator = None
|
|
1331
|
+
if username_ref:
|
|
1332
|
+
# Use provided ref - translate and get visible element
|
|
1333
|
+
username_ref = translate_ref_for_browser(username_ref)
|
|
1334
|
+
elements = await page.locator(username_ref).all()
|
|
1335
|
+
for element in elements:
|
|
1336
|
+
if await element.is_visible():
|
|
1337
|
+
username_locator = element
|
|
1338
|
+
break
|
|
1339
|
+
if not username_locator:
|
|
1340
|
+
username_locator = page.locator(username_ref).first
|
|
1341
|
+
else:
|
|
1342
|
+
# Auto-detect username field
|
|
1343
|
+
# Try autocomplete attributes first (standard way)
|
|
1344
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
|
|
1345
|
+
username_selectors = [
|
|
1346
|
+
'input[autocomplete="username"]',
|
|
1347
|
+
'input[autocomplete="email"]',
|
|
1348
|
+
'input[id="username"]',
|
|
1349
|
+
'input[id="email"]',
|
|
1350
|
+
'input[name="username"]',
|
|
1351
|
+
'input[name="email"]',
|
|
1352
|
+
'input[type="email"]',
|
|
1353
|
+
'input[type="text"][name*="user"]',
|
|
1354
|
+
'input[type="text"][name*="email"]',
|
|
1355
|
+
'input[type="text"][id*="user"]',
|
|
1356
|
+
'input[type="text"][id*="email"]',
|
|
1357
|
+
]
|
|
1358
|
+
|
|
1359
|
+
for selector in username_selectors:
|
|
1360
|
+
try:
|
|
1361
|
+
elements = await page.locator(selector).all()
|
|
1362
|
+
for element in elements:
|
|
1363
|
+
if await element.is_visible():
|
|
1364
|
+
username_locator = element
|
|
1365
|
+
break
|
|
1366
|
+
if username_locator:
|
|
1367
|
+
break
|
|
1368
|
+
except:
|
|
1369
|
+
continue
|
|
1370
|
+
|
|
1371
|
+
password_locator = None
|
|
1372
|
+
if password_ref:
|
|
1373
|
+
# Use provided ref - translate and get visible element
|
|
1374
|
+
password_ref = translate_ref_for_browser(password_ref)
|
|
1375
|
+
elements = await page.locator(password_ref).all()
|
|
1376
|
+
for element in elements:
|
|
1377
|
+
if await element.is_visible():
|
|
1378
|
+
password_locator = element
|
|
1379
|
+
break
|
|
1380
|
+
if not password_locator:
|
|
1381
|
+
password_locator = page.locator(password_ref).first
|
|
1382
|
+
else:
|
|
1383
|
+
# Auto-detect password field
|
|
1384
|
+
# Try autocomplete attributes first (standard way)
|
|
1385
|
+
password_selectors = [
|
|
1386
|
+
'input[autocomplete="current-password"]',
|
|
1387
|
+
'input[autocomplete="new-password"]',
|
|
1388
|
+
'input[id="password"]',
|
|
1389
|
+
'input[name="password"]',
|
|
1390
|
+
'input[type="password"]',
|
|
1391
|
+
]
|
|
1392
|
+
|
|
1393
|
+
for selector in password_selectors:
|
|
1394
|
+
try:
|
|
1395
|
+
elements = await page.locator(selector).all()
|
|
1396
|
+
for element in elements:
|
|
1397
|
+
if await element.is_visible():
|
|
1398
|
+
password_locator = element
|
|
1399
|
+
break
|
|
1400
|
+
if password_locator:
|
|
1401
|
+
break
|
|
1402
|
+
except:
|
|
1403
|
+
continue
|
|
1404
|
+
|
|
1405
|
+
# Fill username field
|
|
1406
|
+
if username_locator:
|
|
1407
|
+
try:
|
|
1408
|
+
await username_locator.fill(matching_cred["username"])
|
|
1409
|
+
results.append(f"filled username field")
|
|
1410
|
+
except Exception as e:
|
|
1411
|
+
results.append(f"failed to fill username: {e}")
|
|
1412
|
+
else:
|
|
1413
|
+
results.append("username field not found")
|
|
1414
|
+
|
|
1415
|
+
# Fill password field
|
|
1416
|
+
if password_locator:
|
|
1417
|
+
try:
|
|
1418
|
+
await password_locator.fill(matching_cred["password"])
|
|
1419
|
+
results.append(f"filled password field")
|
|
1420
|
+
except Exception as e:
|
|
1421
|
+
results.append(f"failed to fill password: {e}")
|
|
1422
|
+
else:
|
|
1423
|
+
results.append("password field not found")
|
|
1424
|
+
|
|
1425
|
+
# Submit if requested
|
|
1426
|
+
if submit and password_locator:
|
|
1427
|
+
try:
|
|
1428
|
+
await password_locator.press("Enter")
|
|
1429
|
+
results.append("submitted form")
|
|
1430
|
+
|
|
1431
|
+
# Wait for page to settle after form submission
|
|
1432
|
+
try:
|
|
1433
|
+
await page.wait_for_load_state("networkidle", timeout=2000)
|
|
1434
|
+
except:
|
|
1435
|
+
await asyncio.sleep(0.5)
|
|
1436
|
+
except Exception as e:
|
|
1437
|
+
results.append(f"failed to submit: {e}")
|
|
1438
|
+
|
|
1439
|
+
result = f"Autofilled credentials for {matching_cred['username']}: {', '.join(results)}"
|
|
1440
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
@mcp.tool()
|
|
1444
|
+
async def browser_autofill_credit_card(
|
|
1445
|
+
card_index: Annotated[Optional[int], "Index of saved card to use (1-based, defaults to first card)"] = 1,
|
|
1446
|
+
name_ref: Annotated[Optional[str], "Selector for cardholder name field"] = None,
|
|
1447
|
+
number_ref: Annotated[Optional[str], "Selector for card number field"] = None,
|
|
1448
|
+
expiry_ref: Annotated[Optional[str], "Selector for expiry date field"] = None,
|
|
1449
|
+
month_ref: Annotated[Optional[str], "Selector for expiry month field (if separate)"] = None,
|
|
1450
|
+
year_ref: Annotated[Optional[str], "Selector for expiry year field (if separate)"] = None,
|
|
1451
|
+
cvv_ref: Annotated[Optional[str], "Selector for CVV field"] = None,
|
|
1452
|
+
) -> Union[str, List[Union[TextContent, ImageContent]]]:
|
|
1453
|
+
"""Autofill payment form with saved credit card"""
|
|
1454
|
+
global _saved_credit_cards
|
|
1455
|
+
page = await ensure_browser()
|
|
1456
|
+
|
|
1457
|
+
if not _saved_credit_cards:
|
|
1458
|
+
return "### Result\nNo saved credit cards available"
|
|
1459
|
+
|
|
1460
|
+
# Get the card to use
|
|
1461
|
+
if card_index < 1 or card_index > len(_saved_credit_cards):
|
|
1462
|
+
return f"### Result\nInvalid card index. Please use a number between 1 and {len(_saved_credit_cards)}"
|
|
1463
|
+
|
|
1464
|
+
card = _saved_credit_cards[card_index - 1]
|
|
1465
|
+
results = []
|
|
1466
|
+
|
|
1467
|
+
# Auto-detect fields if not provided
|
|
1468
|
+
# Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
|
|
1469
|
+
name_locator = None
|
|
1470
|
+
if name_ref:
|
|
1471
|
+
# Use provided ref - translate and get visible element
|
|
1472
|
+
name_ref = translate_ref_for_browser(name_ref)
|
|
1473
|
+
elements = await page.locator(name_ref).all()
|
|
1474
|
+
for element in elements:
|
|
1475
|
+
if await element.is_visible():
|
|
1476
|
+
name_locator = element
|
|
1477
|
+
break
|
|
1478
|
+
if not name_locator:
|
|
1479
|
+
name_locator = page.locator(name_ref).first
|
|
1480
|
+
else:
|
|
1481
|
+
# Auto-detect name field
|
|
1482
|
+
name_selectors = [
|
|
1483
|
+
'input[autocomplete="cc-name"]',
|
|
1484
|
+
'input[autocomplete="name"]',
|
|
1485
|
+
'input[name*="name"][name*="card"]',
|
|
1486
|
+
'input[id*="name"][id*="card"]',
|
|
1487
|
+
'input[placeholder*="name"][placeholder*="card"]',
|
|
1488
|
+
'input[name="cardname"]',
|
|
1489
|
+
'input[name="cardholder"]',
|
|
1490
|
+
'input[id="cardname"]',
|
|
1491
|
+
'input[id="cardholder"]',
|
|
1492
|
+
]
|
|
1493
|
+
|
|
1494
|
+
for selector in name_selectors:
|
|
1495
|
+
try:
|
|
1496
|
+
elements = await page.locator(selector).all()
|
|
1497
|
+
for element in elements:
|
|
1498
|
+
if await element.is_visible():
|
|
1499
|
+
name_locator = element
|
|
1500
|
+
break
|
|
1501
|
+
if name_locator:
|
|
1502
|
+
break
|
|
1503
|
+
except:
|
|
1504
|
+
continue
|
|
1505
|
+
|
|
1506
|
+
number_locator = None
|
|
1507
|
+
if number_ref:
|
|
1508
|
+
# Use provided ref - translate and get visible element
|
|
1509
|
+
number_ref = translate_ref_for_browser(number_ref)
|
|
1510
|
+
elements = await page.locator(number_ref).all()
|
|
1511
|
+
for element in elements:
|
|
1512
|
+
if await element.is_visible():
|
|
1513
|
+
number_locator = element
|
|
1514
|
+
break
|
|
1515
|
+
if not number_locator:
|
|
1516
|
+
number_locator = page.locator(number_ref).first
|
|
1517
|
+
else:
|
|
1518
|
+
# Auto-detect number field
|
|
1519
|
+
number_selectors = [
|
|
1520
|
+
'input[autocomplete="cc-number"]',
|
|
1521
|
+
'input[name*="card"][name*="number"]',
|
|
1522
|
+
'input[id*="card"][id*="number"]',
|
|
1523
|
+
'input[placeholder*="card"][placeholder*="number"]',
|
|
1524
|
+
'input[name="cardnumber"]',
|
|
1525
|
+
'input[name="card_number"]',
|
|
1526
|
+
'input[id="cardnumber"]',
|
|
1527
|
+
'input[id="card_number"]',
|
|
1528
|
+
]
|
|
1529
|
+
|
|
1530
|
+
for selector in number_selectors:
|
|
1531
|
+
try:
|
|
1532
|
+
elements = await page.locator(selector).all()
|
|
1533
|
+
for element in elements:
|
|
1534
|
+
if await element.is_visible():
|
|
1535
|
+
number_locator = element
|
|
1536
|
+
break
|
|
1537
|
+
if number_locator:
|
|
1538
|
+
break
|
|
1539
|
+
except:
|
|
1540
|
+
continue
|
|
1541
|
+
|
|
1542
|
+
cvv_locator = None
|
|
1543
|
+
if cvv_ref:
|
|
1544
|
+
# Use provided ref - translate and get visible element
|
|
1545
|
+
cvv_ref = translate_ref_for_browser(cvv_ref)
|
|
1546
|
+
elements = await page.locator(cvv_ref).all()
|
|
1547
|
+
for element in elements:
|
|
1548
|
+
if await element.is_visible():
|
|
1549
|
+
cvv_locator = element
|
|
1550
|
+
break
|
|
1551
|
+
if not cvv_locator:
|
|
1552
|
+
cvv_locator = page.locator(cvv_ref).first
|
|
1553
|
+
else:
|
|
1554
|
+
# Auto-detect CVV field
|
|
1555
|
+
cvv_selectors = [
|
|
1556
|
+
'input[autocomplete="cc-csc"]',
|
|
1557
|
+
'input[name*="cvv"]',
|
|
1558
|
+
'input[name*="cvc"]',
|
|
1559
|
+
'input[name*="security"]',
|
|
1560
|
+
'input[id*="cvv"]',
|
|
1561
|
+
'input[id*="cvc"]',
|
|
1562
|
+
'input[id*="security"]',
|
|
1563
|
+
'input[placeholder*="cvv"]',
|
|
1564
|
+
'input[placeholder*="cvc"]',
|
|
1565
|
+
'input[placeholder*="security"]',
|
|
1566
|
+
]
|
|
1567
|
+
|
|
1568
|
+
for selector in cvv_selectors:
|
|
1569
|
+
try:
|
|
1570
|
+
elements = await page.locator(selector).all()
|
|
1571
|
+
for element in elements:
|
|
1572
|
+
if await element.is_visible():
|
|
1573
|
+
cvv_locator = element
|
|
1574
|
+
break
|
|
1575
|
+
if cvv_locator:
|
|
1576
|
+
break
|
|
1577
|
+
except:
|
|
1578
|
+
continue
|
|
1579
|
+
|
|
1580
|
+
# Fill cardholder name
|
|
1581
|
+
if name_locator:
|
|
1582
|
+
try:
|
|
1583
|
+
await name_locator.fill(card["name"])
|
|
1584
|
+
results.append("filled name")
|
|
1585
|
+
except Exception as e:
|
|
1586
|
+
results.append(f"failed to fill name: {e}")
|
|
1587
|
+
|
|
1588
|
+
# Fill card number
|
|
1589
|
+
if number_locator:
|
|
1590
|
+
try:
|
|
1591
|
+
await number_locator.fill(card["number"])
|
|
1592
|
+
results.append("filled card number")
|
|
1593
|
+
except Exception as e:
|
|
1594
|
+
results.append(f"failed to fill card number: {e}")
|
|
1595
|
+
|
|
1596
|
+
# Fill expiry date - check if combined or separate fields
|
|
1597
|
+
expiry_locator = None
|
|
1598
|
+
if expiry_ref:
|
|
1599
|
+
# Use provided ref for combined expiry field
|
|
1600
|
+
expiry_ref = translate_ref_for_browser(expiry_ref)
|
|
1601
|
+
elements = await page.locator(expiry_ref).all()
|
|
1602
|
+
for element in elements:
|
|
1603
|
+
if await element.is_visible():
|
|
1604
|
+
expiry_locator = element
|
|
1605
|
+
break
|
|
1606
|
+
if not expiry_locator:
|
|
1607
|
+
expiry_locator = page.locator(expiry_ref).first
|
|
1608
|
+
|
|
1609
|
+
if expiry_locator:
|
|
1610
|
+
# Combined expiry field (MM/YY or MM/YYYY)
|
|
1611
|
+
try:
|
|
1612
|
+
expiry_value = f"{card['expiry_month']}/{card['expiry_year'][-2:]}"
|
|
1613
|
+
await expiry_locator.fill(expiry_value)
|
|
1614
|
+
results.append("filled expiry date")
|
|
1615
|
+
except Exception as e:
|
|
1616
|
+
results.append(f"failed to fill expiry: {e}")
|
|
1617
|
+
else:
|
|
1618
|
+
# Separate month/year fields
|
|
1619
|
+
month_locator = None
|
|
1620
|
+
if month_ref:
|
|
1621
|
+
# Use provided ref
|
|
1622
|
+
month_ref = translate_ref_for_browser(month_ref)
|
|
1623
|
+
elements = await page.locator(month_ref).all()
|
|
1624
|
+
for element in elements:
|
|
1625
|
+
if await element.is_visible():
|
|
1626
|
+
month_locator = element
|
|
1627
|
+
break
|
|
1628
|
+
if not month_locator:
|
|
1629
|
+
month_locator = page.locator(month_ref).first
|
|
1630
|
+
else:
|
|
1631
|
+
# Auto-detect month field
|
|
1632
|
+
month_selectors = [
|
|
1633
|
+
'input[autocomplete="cc-exp-month"]',
|
|
1634
|
+
'select[autocomplete="cc-exp-month"]',
|
|
1635
|
+
'input[name*="month"]',
|
|
1636
|
+
'input[id*="month"]',
|
|
1637
|
+
'select[name*="month"]',
|
|
1638
|
+
'select[id*="month"]',
|
|
1639
|
+
]
|
|
1640
|
+
|
|
1641
|
+
for selector in month_selectors:
|
|
1642
|
+
try:
|
|
1643
|
+
elements = await page.locator(selector).all()
|
|
1644
|
+
for element in elements:
|
|
1645
|
+
if await element.is_visible():
|
|
1646
|
+
month_locator = element
|
|
1647
|
+
break
|
|
1648
|
+
if month_locator:
|
|
1649
|
+
break
|
|
1650
|
+
except:
|
|
1651
|
+
continue
|
|
1652
|
+
|
|
1653
|
+
year_locator = None
|
|
1654
|
+
if year_ref:
|
|
1655
|
+
# Use provided ref
|
|
1656
|
+
year_ref = translate_ref_for_browser(year_ref)
|
|
1657
|
+
elements = await page.locator(year_ref).all()
|
|
1658
|
+
for element in elements:
|
|
1659
|
+
if await element.is_visible():
|
|
1660
|
+
year_locator = element
|
|
1661
|
+
break
|
|
1662
|
+
if not year_locator:
|
|
1663
|
+
year_locator = page.locator(year_ref).first
|
|
1664
|
+
else:
|
|
1665
|
+
# Auto-detect year field
|
|
1666
|
+
year_selectors = [
|
|
1667
|
+
'input[autocomplete="cc-exp-year"]',
|
|
1668
|
+
'select[autocomplete="cc-exp-year"]',
|
|
1669
|
+
'input[name*="year"]',
|
|
1670
|
+
'input[id*="year"]',
|
|
1671
|
+
'select[name*="year"]',
|
|
1672
|
+
'select[id*="year"]',
|
|
1673
|
+
]
|
|
1674
|
+
|
|
1675
|
+
for selector in year_selectors:
|
|
1676
|
+
try:
|
|
1677
|
+
elements = await page.locator(selector).all()
|
|
1678
|
+
for element in elements:
|
|
1679
|
+
if await element.is_visible():
|
|
1680
|
+
year_locator = element
|
|
1681
|
+
break
|
|
1682
|
+
if year_locator:
|
|
1683
|
+
break
|
|
1684
|
+
except:
|
|
1685
|
+
continue
|
|
1686
|
+
|
|
1687
|
+
if month_locator:
|
|
1688
|
+
try:
|
|
1689
|
+
# Check if it's a select or input
|
|
1690
|
+
tag_name = await month_locator.evaluate("el => el.tagName.toLowerCase()")
|
|
1691
|
+
if tag_name == "select":
|
|
1692
|
+
await month_locator.select_option(value=card["expiry_month"])
|
|
1693
|
+
else:
|
|
1694
|
+
await month_locator.fill(card["expiry_month"])
|
|
1695
|
+
results.append("filled expiry month")
|
|
1696
|
+
except Exception as e:
|
|
1697
|
+
results.append(f"failed to fill month: {e}")
|
|
1698
|
+
|
|
1699
|
+
if year_locator:
|
|
1700
|
+
try:
|
|
1701
|
+
tag_name = await year_locator.evaluate("el => el.tagName.toLowerCase()")
|
|
1702
|
+
if tag_name == "select":
|
|
1703
|
+
# Try both 2-digit and 4-digit year
|
|
1704
|
+
try:
|
|
1705
|
+
await year_locator.select_option(value=card["expiry_year"])
|
|
1706
|
+
except:
|
|
1707
|
+
await year_locator.select_option(value=card["expiry_year"][-2:])
|
|
1708
|
+
else:
|
|
1709
|
+
await year_locator.fill(card["expiry_year"][-2:])
|
|
1710
|
+
results.append("filled expiry year")
|
|
1711
|
+
except Exception as e:
|
|
1712
|
+
results.append(f"failed to fill year: {e}")
|
|
1713
|
+
|
|
1714
|
+
# Fill CVV
|
|
1715
|
+
if cvv_locator:
|
|
1716
|
+
try:
|
|
1717
|
+
await cvv_locator.fill(card["cvv"])
|
|
1718
|
+
results.append("filled CVV")
|
|
1719
|
+
except Exception as e:
|
|
1720
|
+
results.append(f"failed to fill CVV: {e}")
|
|
1721
|
+
|
|
1722
|
+
result = f"Autofilled credit card ending in {card['number'][-4:]}: {', '.join(results)}"
|
|
1723
|
+
return await format_response_with_snapshot(result, include_snapshot=True)
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def main():
|
|
1727
|
+
import sys
|
|
1728
|
+
|
|
1729
|
+
print("Starting Browser MCP server...", file=sys.stderr)
|
|
1730
|
+
sys.stderr.flush()
|
|
1731
|
+
|
|
1732
|
+
env_port = os.getenv("PORT", "").strip()
|
|
1733
|
+
if env_port.isdigit():
|
|
1734
|
+
port = int(env_port)
|
|
1735
|
+
else:
|
|
1736
|
+
port = 8850
|
|
1737
|
+
mcp.run(transport="streamable-http", port=port)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
if __name__ == "__main__":
|
|
1741
|
+
main()
|