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.
Files changed (374) hide show
  1. agent/__init__.py +30 -0
  2. agent/claudesdk/__init__.py +8 -0
  3. agent/claudesdk/example.py +221 -0
  4. agent/claudesdk/src/__init__.py +8 -0
  5. agent/claudesdk/src/agent.py +400 -0
  6. agent/claudesdk/src/mcp_proxy.py +409 -0
  7. agent/claudesdk/src/utils.py +420 -0
  8. agent/googleadk/__init__.py +15 -0
  9. agent/googleadk/example.py +237 -0
  10. agent/googleadk/src/__init__.py +12 -0
  11. agent/googleadk/src/agent.py +401 -0
  12. agent/googleadk/src/mcp_wrapper.py +163 -0
  13. agent/googleadk/src/utils.py +602 -0
  14. agent/langchain/__init__.py +8 -0
  15. agent/langchain/example.py +213 -0
  16. agent/langchain/src/__init__.py +8 -0
  17. agent/langchain/src/agent.py +645 -0
  18. agent/langchain/src/utils.py +433 -0
  19. agent/openaisdk/__init__.py +17 -0
  20. agent/openaisdk/example.py +228 -0
  21. agent/openaisdk/src/__init__.py +12 -0
  22. agent/openaisdk/src/agent.py +491 -0
  23. agent/openaisdk/src/agent_wrapper.py +143 -0
  24. agent/openaisdk/src/mcp_wrapper.py +395 -0
  25. agent/openaisdk/src/utils.py +493 -0
  26. agent/openclaw/__init__.py +10 -0
  27. agent/openclaw/example.py +251 -0
  28. agent/openclaw/src/__init__.py +14 -0
  29. agent/openclaw/src/agent.py +930 -0
  30. agent/openclaw/src/helpers/__init__.py +1 -0
  31. agent/openclaw/src/helpers/auth_helpers.py +55 -0
  32. agent/openclaw/src/mcp_proxy.py +564 -0
  33. agent/openclaw/src/plugin_generator.py +231 -0
  34. agent/openclaw/src/utils.py +341 -0
  35. agent/pocketflow/__init__.py +18 -0
  36. agent/pocketflow/example.py +221 -0
  37. agent/pocketflow/prompts/react_agent.py +46 -0
  38. agent/pocketflow/src/__init__.py +6 -0
  39. agent/pocketflow/src/agent.py +507 -0
  40. agent/pocketflow/src/agent_wrapper.py +159 -0
  41. agent/pocketflow/src/async_helper.py +92 -0
  42. agent/pocketflow/src/mcp_react_agent.py +279 -0
  43. agent/pocketflow/src/native_agent.py +74 -0
  44. agent/pocketflow/src/nodes.py +467 -0
  45. benchmark/__init__.py +0 -0
  46. benchmark/browser/benign.jsonl +34 -0
  47. benchmark/browser/direct.jsonl +85 -0
  48. benchmark/browser/indirect.jsonl +82 -0
  49. benchmark/code/benign.jsonl +0 -0
  50. benchmark/code/direct.jsonl +121 -0
  51. benchmark/code/indirect.jsonl +165 -0
  52. benchmark/crm/benign.jsonl +165 -0
  53. benchmark/crm/direct.jsonl +90 -0
  54. benchmark/crm/indirect.jsonl +150 -0
  55. benchmark/customer-service/benign.jsonl +160 -0
  56. benchmark/customer-service/direct.jsonl +100 -0
  57. benchmark/customer-service/indirect.jsonl +101 -0
  58. benchmark/finance/benign.jsonl +0 -0
  59. benchmark/finance/direct.jsonl +200 -0
  60. benchmark/finance/indirect.jsonl +200 -0
  61. benchmark/legal/benign.jsonl +0 -0
  62. benchmark/legal/direct.jsonl +200 -0
  63. benchmark/legal/indirect.jsonl +200 -0
  64. benchmark/macos/benign.jsonl +30 -0
  65. benchmark/macos/direct.jsonl +50 -0
  66. benchmark/macos/indirect.jsonl +50 -0
  67. benchmark/medical/benign.jsonl +642 -0
  68. benchmark/medical/direct.jsonl +229 -0
  69. benchmark/medical/indirect.jsonl +222 -0
  70. benchmark/os-filesystem/benign.jsonl +200 -0
  71. benchmark/os-filesystem/direct.jsonl +200 -0
  72. benchmark/os-filesystem/indirect.jsonl +200 -0
  73. benchmark/research/benign.jsonl +0 -0
  74. benchmark/research/direct.jsonl +119 -0
  75. benchmark/research/indirect.jsonl +125 -0
  76. benchmark/telecom/benign.jsonl +120 -0
  77. benchmark/telecom/direct.jsonl +161 -0
  78. benchmark/telecom/indirect.jsonl +166 -0
  79. benchmark/travel/benign.jsonl +130 -0
  80. benchmark/travel/direct.jsonl +105 -0
  81. benchmark/travel/indirect.jsonl +120 -0
  82. benchmark/windows/benign.jsonl +100 -0
  83. benchmark/windows/direct.jsonl +140 -0
  84. benchmark/windows/indirect.jsonl +107 -0
  85. benchmark/workflow/benign.jsonl +335 -0
  86. benchmark/workflow/direct.jsonl +78 -0
  87. benchmark/workflow/indirect.jsonl +107 -0
  88. cli/__init__.py +5 -0
  89. cli/main.py +182 -0
  90. cli/scaffold.py +334 -0
  91. decodingtrust_agent_sdk-0.1.0.dist-info/METADATA +642 -0
  92. decodingtrust_agent_sdk-0.1.0.dist-info/RECORD +374 -0
  93. decodingtrust_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
  94. decodingtrust_agent_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  95. decodingtrust_agent_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  96. decodingtrust_agent_sdk-0.1.0.dist-info/top_level.txt +6 -0
  97. dt_arena/config/env.yaml +515 -0
  98. dt_arena/config/injection_mcp.yaml +430 -0
  99. dt_arena/config/mcp.yaml +642 -0
  100. dt_arena/envs/arxiv/docker-compose-hub.yml +31 -0
  101. dt_arena/envs/arxiv/docker-compose.yml +36 -0
  102. dt_arena/envs/atlassian/docker/docker-compose.dev.yml +65 -0
  103. dt_arena/envs/atlassian/docker/docker-compose.yml +53 -0
  104. dt_arena/envs/atlassian/docker-compose-hub.yml +57 -0
  105. dt_arena/envs/atlassian/docker-compose.yml +72 -0
  106. dt_arena/envs/bigquery/docker-compose.yml +20 -0
  107. dt_arena/envs/booking/docker-compose.yml +59 -0
  108. dt_arena/envs/calendar/docker-compose-hub.yml +30 -0
  109. dt_arena/envs/calendar/docker-compose.yml +42 -0
  110. dt_arena/envs/custom-website/docker-compose.yml +6 -0
  111. dt_arena/envs/customer_service/docker-compose.yml +59 -0
  112. dt_arena/envs/databricks/docker-compose-hub.yml +47 -0
  113. dt_arena/envs/databricks/docker-compose.yml +51 -0
  114. dt_arena/envs/ecommerce/docker-compose.yml +6 -0
  115. dt_arena/envs/ers/docker-compose.yml +36 -0
  116. dt_arena/envs/ers/hrms/docker/docker-compose.yml +31 -0
  117. dt_arena/envs/finance/docker-compose.yml +23 -0
  118. dt_arena/envs/github/docker/docker-compose-hub.yml +50 -0
  119. dt_arena/envs/github/docker/docker-compose.yml +50 -0
  120. dt_arena/envs/gmail/docker-compose-hub.yml +51 -0
  121. dt_arena/envs/gmail/docker-compose.yml +65 -0
  122. dt_arena/envs/google-form/docker-compose-hub.yml +33 -0
  123. dt_arena/envs/google-form/docker-compose.yml +41 -0
  124. dt_arena/envs/googledocs/docker-compose-hub.yml +61 -0
  125. dt_arena/envs/googledocs/docker-compose.yml +78 -0
  126. dt_arena/envs/hospital/docker-compose-hub.yml +25 -0
  127. dt_arena/envs/hospital/docker-compose.yml +27 -0
  128. dt_arena/envs/legal/docker-compose.yml +22 -0
  129. dt_arena/envs/linkedin/docker-compose.yml +63 -0
  130. dt_arena/envs/macos/docker-compose.yml +79 -0
  131. dt_arena/envs/os-filesystem/docker-compose-hub.yml +16 -0
  132. dt_arena/envs/os-filesystem/docker-compose.yml +20 -0
  133. dt_arena/envs/paypal/docker-compose-hub.yml +48 -0
  134. dt_arena/envs/paypal/docker-compose.yml +63 -0
  135. dt_arena/envs/research/docker-compose-hub.yml +13 -0
  136. dt_arena/envs/research/docker-compose.yml +24 -0
  137. dt_arena/envs/salesforce_crm/docker-compose-hub.yaml +45 -0
  138. dt_arena/envs/salesforce_crm/docker-compose.yaml +49 -0
  139. dt_arena/envs/slack/docker-compose-hub.yml +28 -0
  140. dt_arena/envs/slack/docker-compose.yml +41 -0
  141. dt_arena/envs/snowflake/docker-compose-hub.yml +41 -0
  142. dt_arena/envs/snowflake/docker-compose.yml +44 -0
  143. dt_arena/envs/telecom/docker-compose-hub.yml +16 -0
  144. dt_arena/envs/telecom/docker-compose.yml +17 -0
  145. dt_arena/envs/telegram/docker-compose-hub.yml +57 -0
  146. dt_arena/envs/telegram/docker-compose.yml +62 -0
  147. dt_arena/envs/terminal/docker-compose-hub.yml +12 -0
  148. dt_arena/envs/terminal/docker-compose.yml +26 -0
  149. dt_arena/envs/travel/docker-compose-hub.yml +19 -0
  150. dt_arena/envs/travel/docker-compose.yml +19 -0
  151. dt_arena/envs/whatsapp/docker-compose-hub.yml +61 -0
  152. dt_arena/envs/whatsapp/docker-compose.yml +78 -0
  153. dt_arena/envs/windows/docker-compose.yml +71 -0
  154. dt_arena/envs/zoom/docker-compose-hub.yml +27 -0
  155. dt_arena/envs/zoom/docker-compose.yml +40 -0
  156. dt_arena/injection_mcp_server/atlassian/env_injection.py +134 -0
  157. dt_arena/injection_mcp_server/calendar/env_injection.py +217 -0
  158. dt_arena/injection_mcp_server/custom_website/env_injection.py +97 -0
  159. dt_arena/injection_mcp_server/customer_service/env_injection.py +659 -0
  160. dt_arena/injection_mcp_server/databricks/env_injection.py +255 -0
  161. dt_arena/injection_mcp_server/ecommerce/env_injection.py +110 -0
  162. dt_arena/injection_mcp_server/finance/env_injection.py +85 -0
  163. dt_arena/injection_mcp_server/github/env_injection.py +206 -0
  164. dt_arena/injection_mcp_server/gmail/env_injection.py +211 -0
  165. dt_arena/injection_mcp_server/google_form/env_injection.py +186 -0
  166. dt_arena/injection_mcp_server/googledocs/env_injection.py +44 -0
  167. dt_arena/injection_mcp_server/hospital/env_injection.py +43 -0
  168. dt_arena/injection_mcp_server/legal/env_injection.py +229 -0
  169. dt_arena/injection_mcp_server/macos/env_injection.py +272 -0
  170. dt_arena/injection_mcp_server/os-filesystem/env_injection.py +341 -0
  171. dt_arena/injection_mcp_server/paypal/env_injection.py +268 -0
  172. dt_arena/injection_mcp_server/research/env_injection.py +616 -0
  173. dt_arena/injection_mcp_server/salesforce/env_injection.py +514 -0
  174. dt_arena/injection_mcp_server/slack/env_injection.py +265 -0
  175. dt_arena/injection_mcp_server/snowflake/env_injection.py +230 -0
  176. dt_arena/injection_mcp_server/telecom/env_injection.py +503 -0
  177. dt_arena/injection_mcp_server/telegram/env_injection.py +171 -0
  178. dt_arena/injection_mcp_server/terminal/env_injection.py +523 -0
  179. dt_arena/injection_mcp_server/travel/env_injection.py +173 -0
  180. dt_arena/injection_mcp_server/whatsapp/env_injection.py +185 -0
  181. dt_arena/injection_mcp_server/windows/env_injection.py +943 -0
  182. dt_arena/injection_mcp_server/zoom/env_injection.py +216 -0
  183. dt_arena/mcp_server/atlassian/main.py +1554 -0
  184. dt_arena/mcp_server/atlassian/test_server.py +66 -0
  185. dt_arena/mcp_server/bigquery/main.py +333 -0
  186. dt_arena/mcp_server/booking/main.py +310 -0
  187. dt_arena/mcp_server/browser/main.py +1741 -0
  188. dt_arena/mcp_server/calendar/example_multi_user.py +162 -0
  189. dt_arena/mcp_server/calendar/main.py +792 -0
  190. dt_arena/mcp_server/calendar/test_mcp.py +135 -0
  191. dt_arena/mcp_server/customer_service/main.py +1063 -0
  192. dt_arena/mcp_server/databricks/main.py +566 -0
  193. dt_arena/mcp_server/databricks/probe.py +102 -0
  194. dt_arena/mcp_server/ers/main.py +845 -0
  195. dt_arena/mcp_server/finance/__init__.py +87 -0
  196. dt_arena/mcp_server/finance/core/__init__.py +12 -0
  197. dt_arena/mcp_server/finance/core/data_loader.py +558 -0
  198. dt_arena/mcp_server/finance/core/portfolio.py +565 -0
  199. dt_arena/mcp_server/finance/evaluation/__init__.py +20 -0
  200. dt_arena/mcp_server/finance/evaluation/evaluator.py +217 -0
  201. dt_arena/mcp_server/finance/evaluation/logger.py +137 -0
  202. dt_arena/mcp_server/finance/injection/__init__.py +66 -0
  203. dt_arena/mcp_server/finance/injection/config.py +176 -0
  204. dt_arena/mcp_server/finance/injection/content.py +755 -0
  205. dt_arena/mcp_server/finance/injection/html.py +409 -0
  206. dt_arena/mcp_server/finance/injection/locations.py +167 -0
  207. dt_arena/mcp_server/finance/injection/methods.py +193 -0
  208. dt_arena/mcp_server/finance/injection/presets.py +1023 -0
  209. dt_arena/mcp_server/finance/main.py +361 -0
  210. dt_arena/mcp_server/finance/run_mcp.py +21 -0
  211. dt_arena/mcp_server/finance/run_web.py +26 -0
  212. dt_arena/mcp_server/finance/server/__init__.py +41 -0
  213. dt_arena/mcp_server/finance/server/extractor.py +1453 -0
  214. dt_arena/mcp_server/finance/server/extractor_minimal.py +292 -0
  215. dt_arena/mcp_server/finance/server/extractor_simple.py +1164 -0
  216. dt_arena/mcp_server/finance/server/injection_mcp.py +865 -0
  217. dt_arena/mcp_server/finance/server/mcp.py +451 -0
  218. dt_arena/mcp_server/finance/server/tools/__init__.py +23 -0
  219. dt_arena/mcp_server/finance/server/tools/account.py +88 -0
  220. dt_arena/mcp_server/finance/server/tools/browsing.py +328 -0
  221. dt_arena/mcp_server/finance/server/tools/social.py +73 -0
  222. dt_arena/mcp_server/finance/server/tools/trading.py +242 -0
  223. dt_arena/mcp_server/finance/server/tools/utility.py +49 -0
  224. dt_arena/mcp_server/finance/server/web.py +2139 -0
  225. dt_arena/mcp_server/finance/tasks/benchmark/__init__.py +28 -0
  226. dt_arena/mcp_server/finance/tasks/benchmark/attack_pool.py +3026 -0
  227. dt_arena/mcp_server/finance/tasks/benchmark/attack_runner.py +1315 -0
  228. dt_arena/mcp_server/finance/tasks/benchmark/finra_requirements.py +1335 -0
  229. dt_arena/mcp_server/finance/tasks/benchmark/finra_tasks.py +3665 -0
  230. dt_arena/mcp_server/finance/tasks/benchmark/malicious_tasks.py +2673 -0
  231. dt_arena/mcp_server/finance/tasks/redteam_suite/run_redteam_suite.py +1713 -0
  232. dt_arena/mcp_server/finance/test_mcp_tools.py +476 -0
  233. dt_arena/mcp_server/github/main.py +441 -0
  234. dt_arena/mcp_server/gmail/main.py +1004 -0
  235. dt_arena/mcp_server/google_form/main.py +141 -0
  236. dt_arena/mcp_server/googledocs/main.py +458 -0
  237. dt_arena/mcp_server/hospital/mcp_server.py +458 -0
  238. dt_arena/mcp_server/legal/__init__.py +9 -0
  239. dt_arena/mcp_server/legal/core/__init__.py +14 -0
  240. dt_arena/mcp_server/legal/core/courtlistener_store.py +762 -0
  241. dt_arena/mcp_server/legal/core/data_loader.py +266 -0
  242. dt_arena/mcp_server/legal/core/document_store.py +197 -0
  243. dt_arena/mcp_server/legal/core/matter_manager.py +466 -0
  244. dt_arena/mcp_server/legal/main.py +89 -0
  245. dt_arena/mcp_server/legal/scripts/collect_data.py +988 -0
  246. dt_arena/mcp_server/legal/server/__init__.py +14 -0
  247. dt_arena/mcp_server/legal/server/mcp.py +2330 -0
  248. dt_arena/mcp_server/macos/client_test.py +270 -0
  249. dt_arena/mcp_server/macos/mcp_server.py +285 -0
  250. dt_arena/mcp_server/os-filesystem/main.py +1380 -0
  251. dt_arena/mcp_server/paypal/main.py +501 -0
  252. dt_arena/mcp_server/research/main.py +777 -0
  253. dt_arena/mcp_server/salesforce/main.py +2006 -0
  254. dt_arena/mcp_server/slack/main.py +318 -0
  255. dt_arena/mcp_server/snowflake/main.py +612 -0
  256. dt_arena/mcp_server/snowflake/probe.py +183 -0
  257. dt_arena/mcp_server/telecom/mcp_client.py +423 -0
  258. dt_arena/mcp_server/telecom/mcp_server.py +1059 -0
  259. dt_arena/mcp_server/telegram/main.py +338 -0
  260. dt_arena/mcp_server/terminal/main.py +163 -0
  261. dt_arena/mcp_server/travel/client_test.py +16 -0
  262. dt_arena/mcp_server/travel/mcp_server.py +404 -0
  263. dt_arena/mcp_server/whatsapp/main.py +318 -0
  264. dt_arena/mcp_server/windows/client_test.py +270 -0
  265. dt_arena/mcp_server/windows/mcp_server.py +218 -0
  266. dt_arena/mcp_server/zoom/main.py +466 -0
  267. dt_arena/src/__init__.py +0 -0
  268. dt_arena/src/hooks/__init__.py +0 -0
  269. dt_arena/src/hooks/audit_log.py +30 -0
  270. dt_arena/src/hooks/hooks.json +3 -0
  271. dt_arena/src/run_benign.py +142 -0
  272. dt_arena/src/types/__init__.py +0 -0
  273. dt_arena/src/types/agent.py +441 -0
  274. dt_arena/src/types/attacks.py +2 -0
  275. dt_arena/src/types/environment.py +2 -0
  276. dt_arena/src/types/hooks.py +174 -0
  277. dt_arena/src/types/judge.py +52 -0
  278. dt_arena/src/types/red_teaming_trajectory.py +385 -0
  279. dt_arena/src/types/task.py +260 -0
  280. dt_arena/src/types/trajectory.py +315 -0
  281. dt_arena/utils/__init__.py +1 -0
  282. dt_arena/utils/atlassian/__init__.py +27 -0
  283. dt_arena/utils/atlassian/helpers.py +520 -0
  284. dt_arena/utils/bigquery/__init__.py +1 -0
  285. dt_arena/utils/bigquery/helpers.py +246 -0
  286. dt_arena/utils/calendar/__init__.py +1 -0
  287. dt_arena/utils/calendar/helpers.py +87 -0
  288. dt_arena/utils/customer_service/__init__.py +17 -0
  289. dt_arena/utils/customer_service/cs_env_client.py +940 -0
  290. dt_arena/utils/customer_service/helpers.py +339 -0
  291. dt_arena/utils/customer_service/judges/__init__.py +20 -0
  292. dt_arena/utils/customer_service/judges/abusive_comms.py +149 -0
  293. dt_arena/utils/customer_service/judges/bulk_destructive.py +202 -0
  294. dt_arena/utils/customer_service/judges/cross_customer.py +481 -0
  295. dt_arena/utils/customer_service/judges/email_exfiltration.py +320 -0
  296. dt_arena/utils/customer_service/judges/financial_fraud.py +221 -0
  297. dt_arena/utils/customer_service/judges/llm_utils.py +264 -0
  298. dt_arena/utils/customer_service/judges/pii_disclosure.py +837 -0
  299. dt_arena/utils/customer_service/judges/policy_misrep.py +44 -0
  300. dt_arena/utils/customer_service/judges/text_utils.py +21 -0
  301. dt_arena/utils/databricks/__init__.py +2 -0
  302. dt_arena/utils/databricks/helpers.py +210 -0
  303. dt_arena/utils/finance/__init__.py +0 -0
  304. dt_arena/utils/finance/helpers.py +263 -0
  305. dt_arena/utils/github/__init__.py +1 -0
  306. dt_arena/utils/github/helpers.py +249 -0
  307. dt_arena/utils/gmail/__init__.py +1 -0
  308. dt_arena/utils/gmail/helpers.py +344 -0
  309. dt_arena/utils/google_form/__init__.py +2 -0
  310. dt_arena/utils/google_form/helpers.py +133 -0
  311. dt_arena/utils/legal/__init__.py +0 -0
  312. dt_arena/utils/legal/helpers.py +228 -0
  313. dt_arena/utils/macos/__init__.py +0 -0
  314. dt_arena/utils/macos/env_setup.py +215 -0
  315. dt_arena/utils/macos/helpers.py +61 -0
  316. dt_arena/utils/os_filesystem/__init__.py +1 -0
  317. dt_arena/utils/os_filesystem/helpers.py +366 -0
  318. dt_arena/utils/paypal/__init__.py +1 -0
  319. dt_arena/utils/paypal/helpers.py +178 -0
  320. dt_arena/utils/port_allocator.py +266 -0
  321. dt_arena/utils/research/__init__.py +0 -0
  322. dt_arena/utils/research/helpers.py +251 -0
  323. dt_arena/utils/salesforce/__init__.py +1 -0
  324. dt_arena/utils/salesforce/helpers.py +719 -0
  325. dt_arena/utils/slack/__init__.py +1 -0
  326. dt_arena/utils/slack/helpers.py +176 -0
  327. dt_arena/utils/snowflake/__init__.py +1 -0
  328. dt_arena/utils/snowflake/helpers.py +166 -0
  329. dt_arena/utils/telecom/__init__.py +1 -0
  330. dt_arena/utils/telecom/helpers.py +760 -0
  331. dt_arena/utils/telegram/__init__.py +0 -0
  332. dt_arena/utils/telegram/helpers.py +174 -0
  333. dt_arena/utils/terminal/__init__.py +0 -0
  334. dt_arena/utils/terminal/helpers.py +20 -0
  335. dt_arena/utils/travel/__init__.py +0 -0
  336. dt_arena/utils/travel/env_client.py +537 -0
  337. dt_arena/utils/travel/llm_judge.py +137 -0
  338. dt_arena/utils/travel/prompts.py +64 -0
  339. dt_arena/utils/utils/__init__.py +122 -0
  340. dt_arena/utils/whatsapp/__init__.py +0 -0
  341. dt_arena/utils/whatsapp/helpers.py +226 -0
  342. dt_arena/utils/windows/__init__.py +0 -0
  343. dt_arena/utils/windows/env_reset.py +224 -0
  344. dt_arena/utils/windows/env_setup.py +280 -0
  345. dt_arena/utils/windows/exfil_helpers.py +170 -0
  346. dt_arena/utils/windows/helpers.py +74 -0
  347. dt_arena/utils/zoom/__init__.py +1 -0
  348. dt_arena/utils/zoom/helpers.py +70 -0
  349. eval/__init__.py +1 -0
  350. eval/evaluation.py +426 -0
  351. eval/task_runner.py +449 -0
  352. utils/__init__.py +148 -0
  353. utils/agent_helpers.py +308 -0
  354. utils/agent_wrapper.py +189 -0
  355. utils/compose_utils.py +135 -0
  356. utils/config.py +77 -0
  357. utils/env_helpers.py +104 -0
  358. utils/eval_stats.py +88 -0
  359. utils/injection_helpers.py +429 -0
  360. utils/injection_mcp_helpers.py +152 -0
  361. utils/judge_helpers.py +181 -0
  362. utils/judge_utils.py +472 -0
  363. utils/llm.py +196 -0
  364. utils/logging.py +45 -0
  365. utils/mcp_helpers.py +232 -0
  366. utils/mcp_manager.py +235 -0
  367. utils/memory_guard.py +18 -0
  368. utils/red_teaming_sandbox.py +476 -0
  369. utils/reset_helpers.py +318 -0
  370. utils/resource_manager.py +370 -0
  371. utils/skill_helpers.py +447 -0
  372. utils/task_executor.py +904 -0
  373. utils/task_helpers.py +270 -0
  374. 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()