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,1004 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Gmail MCP server (sandboxed) backed by Mailpit.
4
+ Preserves tool signature: get_gmail_content(limit:int)->str
5
+ Also exposes list_messages, get_message, delete_all_messages, find_message for direct tooling.
6
+ """
7
+ import os
8
+ import asyncio
9
+ import json
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import httpx
14
+ from fastmcp import FastMCP
15
+ from pathlib import Path
16
+ try:
17
+ import yaml # type: ignore
18
+ except Exception:
19
+ yaml = None
20
+ import smtplib
21
+ from email.message import EmailMessage
22
+ from email.utils import getaddresses
23
+
24
+ # Mailpit config
25
+ MAILPIT_BASE_URL = os.getenv("MAILPIT_BASE_URL", "http://localhost:8025")
26
+ # Use API Proxy for user-specific filtering
27
+ API_PROXY_URL = os.getenv("API_PROXY_URL", "http://localhost:8031")
28
+ MAILPIT_MESSAGES_API = f"{API_PROXY_URL}/api/v1/messages"
29
+ MAILPIT_MESSAGE_API = f"{API_PROXY_URL}/api/v1/message"
30
+ MAILPIT_SMTP_HOST = os.getenv("MAILPIT_SMTP_HOST", "localhost")
31
+ MAILPIT_SMTP_PORT = int(os.getenv("MAILPIT_SMTP_PORT", "1025"))
32
+
33
+ # User authentication
34
+ USER_ACCESS_TOKEN = os.getenv("USER_ACCESS_TOKEN", "tok_8Q85y4r9x30fS3kEgvSYGQaITM59VFKULz64lTI00Fc")
35
+
36
+ # Debug: Print token status on startup
37
+ import sys
38
+ print(f"[MCP Server] ===== STARTING =====", file=sys.stderr)
39
+ print(f"[MCP Server] USER_ACCESS_TOKEN: {USER_ACCESS_TOKEN[:20] if USER_ACCESS_TOKEN else 'NONE'}...", file=sys.stderr)
40
+ print(f"[MCP Server] API_PROXY_URL: {API_PROXY_URL}", file=sys.stderr)
41
+ print(f"[MCP Server] AUTH_API_URL: {os.getenv('AUTH_API_URL', 'NOT SET')}", file=sys.stderr)
42
+ print(f"[MCP Server] MAILPIT_SMTP_HOST: {MAILPIT_SMTP_HOST}", file=sys.stderr)
43
+ print(f"[MCP Server] ==================", file=sys.stderr)
44
+ sys.stderr.flush()
45
+
46
+ # Create a FastMCP server
47
+ mcp = FastMCP("Gmail Client")
48
+
49
+ _http_client: Optional[httpx.AsyncClient] = None
50
+
51
+
52
+ def _get_auth_headers() -> Dict[str, str]:
53
+ """Get authentication headers with access token from env."""
54
+ headers = {
55
+ "Content-Type": "application/json",
56
+ "Accept-Encoding": "identity" # Disable compression to avoid decoding errors
57
+ }
58
+ if USER_ACCESS_TOKEN:
59
+ headers["Authorization"] = f"Bearer {USER_ACCESS_TOKEN}"
60
+ return headers
61
+
62
+
63
+ async def get_http() -> httpx.AsyncClient:
64
+ """Get HTTP client with access token from env."""
65
+ global _http_client
66
+
67
+ if _http_client is None:
68
+ _http_client = httpx.AsyncClient(
69
+ timeout=20.0,
70
+ headers=_get_auth_headers()
71
+ )
72
+ return _http_client
73
+
74
+
75
+
76
+ async def _get_current_user_email() -> Optional[str]:
77
+ """Get the email address of the current authenticated user.
78
+
79
+ Retries up to 10 times to tolerate auth service cold start.
80
+ """
81
+
82
+ if not USER_ACCESS_TOKEN:
83
+ return None
84
+
85
+ import asyncio
86
+ auth_url = os.getenv("AUTH_API_URL", "http://localhost:8030")
87
+
88
+ for attempt in range(1, 11):
89
+ try:
90
+ client = await get_http()
91
+ resp = await client.get(
92
+ f"{auth_url}/api/v1/auth/me",
93
+ headers={"Authorization": f"Bearer {USER_ACCESS_TOKEN}"},
94
+ timeout=3.0,
95
+ )
96
+ if resp.status_code == 200:
97
+ user_data = resp.json()
98
+ email = user_data.get("email")
99
+ if email:
100
+ return email
101
+ except Exception:
102
+ pass
103
+ await asyncio.sleep(min(0.3 * attempt, 2.0))
104
+
105
+ return None
106
+
107
+
108
+ def _get_msg_id(m: Dict[str, Any]) -> str:
109
+ return str(m.get("ID") or m.get("Id") or m.get("id") or "")
110
+
111
+
112
+ def _safe_lower(value: Optional[str]) -> str:
113
+ return (value or "").strip().lower()
114
+
115
+
116
+ def _pick(d: Dict[str, Any], *keys: str) -> Optional[str]:
117
+ for k in keys:
118
+ if k in d and d[k]:
119
+ return d[k]
120
+ return None
121
+
122
+
123
+ def _parse_datetime(value: Optional[str]) -> str:
124
+ if not value:
125
+ return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
126
+ try:
127
+ if value.isdigit():
128
+ ts = int(value)
129
+ if ts > 10_000_000_000:
130
+ ts //= 1000
131
+ return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
132
+ except Exception:
133
+ pass
134
+ try:
135
+ from email.utils import parsedate_to_datetime
136
+ return parsedate_to_datetime(value).strftime('%Y-%m-%d %H:%M:%S')
137
+ except Exception:
138
+ try:
139
+ return datetime.fromisoformat(value).strftime('%Y-%m-%d %H:%M:%S')
140
+ except Exception:
141
+ return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
142
+
143
+
144
+ async def _fetch_messages(limit_scan: int) -> List[Dict[str, Any]]:
145
+ client = await get_http()
146
+ # Request more messages to ensure we find inbox messages among sent ones
147
+ api_limit = max(200, limit_scan * 10)
148
+ resp = await client.get(
149
+ f"{MAILPIT_MESSAGES_API}?limit={api_limit}",
150
+ headers=_get_auth_headers()
151
+ )
152
+ resp.raise_for_status()
153
+ data = resp.json()
154
+ messages: List[Dict[str, Any]]
155
+ if isinstance(data, dict) and isinstance(data.get("messages"), list):
156
+ messages = data["messages"]
157
+ elif isinstance(data, list):
158
+ messages = data
159
+ else:
160
+ messages = []
161
+ return messages
162
+
163
+
164
+ async def _fetch_message_detail(msg_id: str) -> Dict[str, Any]:
165
+ client = await get_http()
166
+ resp = await client.get(
167
+ f"{MAILPIT_MESSAGE_API}/{msg_id}",
168
+ headers=_get_auth_headers()
169
+ )
170
+ if resp.status_code == 404:
171
+ return {}
172
+ resp.raise_for_status()
173
+ return resp.json()
174
+
175
+
176
+ def _extract_headers(detail: Dict[str, Any]) -> Dict[str, str]:
177
+ headers: Dict[str, str] = {}
178
+ raw_headers = detail.get("Headers") or detail.get("headers") or {}
179
+ if isinstance(raw_headers, dict):
180
+ for k, v in raw_headers.items():
181
+ if isinstance(v, list) and v:
182
+ headers[k] = v[0]
183
+ elif isinstance(v, str):
184
+ headers[k] = v
185
+ if not headers.get("Subject") and detail.get("Subject"):
186
+ headers["Subject"] = detail.get("Subject")
187
+ if not headers.get("From") and detail.get("From"):
188
+ headers["From"] = detail.get("From")
189
+ if not headers.get("To") and detail.get("To"):
190
+ headers["To"] = detail.get("To")
191
+ if not headers.get("Date") and detail.get("Date"):
192
+ headers["Date"] = detail.get("Date")
193
+ return headers
194
+
195
+
196
+ def _extract_body(detail: Dict[str, Any]) -> str:
197
+ text = _pick(detail, "Text", "text")
198
+ if isinstance(text, str) and text.strip():
199
+ return text
200
+ html = _pick(detail, "HTML", "html")
201
+ if isinstance(html, str) and html.strip():
202
+ try:
203
+ import re
204
+ return re.sub(r"<[^>]+>", "", html)
205
+ except Exception:
206
+ return html
207
+ return ""
208
+
209
+
210
+ def _make_thread_id(subject: str) -> str:
211
+ import hashlib
212
+ s = (subject or "").strip().lower().encode("utf-8")
213
+ return f"subj-{hashlib.sha1(s).hexdigest()[:16]}"
214
+
215
+
216
+ def _as_text(value: Any) -> str:
217
+ if value is None:
218
+ return ""
219
+ if isinstance(value, list):
220
+ return " ".join(str(x) for x in value)
221
+ return str(value)
222
+
223
+
224
+ def _addresses_from_field(value: Any) -> List[str]:
225
+ raw_parts: List[str] = []
226
+ extracted: List[str] = []
227
+
228
+ def add_from_item(item: Any) -> None:
229
+ if item is None:
230
+ return
231
+ if isinstance(item, dict):
232
+ found = False
233
+ for key in ("Address", "address", "Email", "email"):
234
+ if key in item and item[key]:
235
+ extracted.append(str(item[key]))
236
+ found = True
237
+ break
238
+ if not found:
239
+ raw_parts.append(str(item))
240
+ else:
241
+ raw_parts.append(str(item))
242
+
243
+ if isinstance(value, list):
244
+ for it in value:
245
+ add_from_item(it)
246
+ elif isinstance(value, dict):
247
+ add_from_item(value)
248
+ elif value is not None:
249
+ raw_parts.append(str(value))
250
+
251
+ extracted += [addr for _, addr in getaddresses(raw_parts) if addr]
252
+ return [a.lower() for a in extracted if a]
253
+
254
+
255
+ async def _try_delete_variants(client: httpx.AsyncClient, msg_id: str) -> httpx.Response | None:
256
+ """Try multiple endpoint patterns for single delete across Mailpit variants."""
257
+ candidates: List[Tuple[str, str, Dict[str, str] | None, Any]] = [
258
+ # Put the confirmed-working endpoint first
259
+ ("DELETE", f"{MAILPIT_MESSAGES_API}?id={msg_id}", None, None),
260
+ # Other possible variants
261
+ ("DELETE", f"{MAILPIT_MESSAGE_API}?id={msg_id}", None, None),
262
+ ("DELETE", f"{MAILPIT_MESSAGE_API}/{msg_id}", None, None),
263
+ ("DELETE", f"{MAILPIT_MESSAGES_API}/{msg_id}", None, None),
264
+ # Method override via POST
265
+ ("POST", f"{MAILPIT_MESSAGE_API}/{msg_id}", {"X-HTTP-Method-Override": "DELETE"}, None),
266
+ ("POST", f"{MAILPIT_MESSAGES_API}/{msg_id}", {"X-HTTP-Method-Override": "DELETE"}, None),
267
+ # Bulk-style JSON payload with a single id (in case supported)
268
+ ("POST", f"{MAILPIT_MESSAGES_API}", {"Content-Type": "application/json"}, json.dumps({"ids": [msg_id]})),
269
+ ]
270
+ last: Optional[httpx.Response] = None
271
+ for method, url, headers, data in candidates:
272
+ try:
273
+ if method == "DELETE":
274
+ r = await client.request("DELETE", url, headers=headers)
275
+ else:
276
+ r = await client.request(method, url, headers=headers, content=data)
277
+ if r.status_code in (200, 204):
278
+ return r
279
+ last = r
280
+ except Exception:
281
+ continue
282
+ return last
283
+
284
+
285
+ async def _delete_by_id_strict(client: httpx.AsyncClient, msg_id: str) -> httpx.Response | None:
286
+ """Delete a single message using Mailpit's batch delete API with IDs array.
287
+
288
+ CRITICAL: Mailpit API behavior:
289
+ - DELETE /api/v1/messages with body {"IDs": ["id1", "id2"]} → deletes specified messages
290
+ - DELETE /api/v1/messages with NO body → deletes ALL messages!
291
+
292
+ We MUST ensure the JSON body is always sent with proper Content-Type header.
293
+ """
294
+ try:
295
+ if not msg_id or not msg_id.strip():
296
+ raise ValueError("msg_id cannot be empty")
297
+
298
+ # Mailpit requires: DELETE /api/v1/messages with body {"IDs": ["id"]}
299
+ # Note: uppercase "IDs" is required!
300
+ payload = {"IDs": [msg_id.strip()]}
301
+
302
+ # httpx.delete() doesn't support json parameter, use request() instead
303
+ headers = {
304
+ "Content-Type": "application/json",
305
+ "Accept-Encoding": "identity",
306
+ }
307
+
308
+ r = await client.request(
309
+ "DELETE",
310
+ MAILPIT_MESSAGES_API,
311
+ content=json.dumps(payload),
312
+ headers=headers
313
+ )
314
+ return r
315
+ except Exception as e:
316
+ # Return a mock response with error
317
+ class ErrorResponse:
318
+ status_code = 500
319
+ text = str(e)
320
+ return ErrorResponse()
321
+
322
+
323
+ @mcp.tool()
324
+ async def get_gmail_content(limit: int = 20) -> str:
325
+ """Return simplified Gmail threads from recent inbox messages.
326
+
327
+ Args:
328
+ limit: Number of recent threads to include (default 20, max 200)
329
+
330
+ Returns:
331
+ JSON object {"threads": [...]} with id/from/to/subject/snippet/created fields.
332
+ """
333
+ try:
334
+ threads = await _build_threads(limit_threads=max(1, min(200, limit)))
335
+ result = {"threads": threads}
336
+ return json.dumps(result, ensure_ascii=False, indent=2)
337
+ except httpx.HTTPError as e:
338
+ return json.dumps({"error": f"Mailpit HTTP error: {e}"}, ensure_ascii=False)
339
+ except Exception as e: # pragma: no cover
340
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
341
+
342
+
343
+ # Lightweight thread builder: list recent messages and group by subject
344
+ async def _build_threads(limit_threads: int = 20) -> list[dict]:
345
+ """
346
+ Build a simple list of 'threads' from recent messages.
347
+ We approximate threads by grouping messages with the same subject.
348
+ """
349
+ client = await get_http()
350
+ resp = await client.get(f"{MAILPIT_MESSAGES_API}?limit={max(1, min(500, limit_threads))}")
351
+ if resp.status_code != 200:
352
+ return [{"error": f"HTTP {resp.status_code}: {resp.text}"}]
353
+ data = resp.json() or {}
354
+ messages = data.get("messages") or []
355
+ # Normalize minimal fields
356
+ out = []
357
+ for m in messages:
358
+ out.append({
359
+ "id": m.get("ID") or m.get("id"),
360
+ "from": (m.get("From") or {}).get("Address") if isinstance(m.get("From"), dict) else m.get("From"),
361
+ "to": m.get("To"),
362
+ "subject": m.get("Subject"),
363
+ "snippet": m.get("Snippet"),
364
+ "created": m.get("Created"),
365
+ })
366
+ return out
367
+
368
+
369
+ @mcp.tool()
370
+ async def list_messages(limit: int = 50) -> str:
371
+ """List received emails (inbox) - messages where you are a recipient (To or Cc).
372
+ Use this to view, browse, or list your inbox emails. This is a READ-ONLY operation.
373
+
374
+ Args:
375
+ limit: Maximum number of messages to return (default: 50, max: 500)
376
+
377
+ Returns:
378
+ JSON array of inbox messages (emails received by the authenticated user)
379
+ """
380
+ try:
381
+ user_email = await _get_current_user_email()
382
+ if not user_email:
383
+ return json.dumps({"error": "Authentication required to list inbox"})
384
+
385
+ user_email_lower = user_email.lower()
386
+ msgs = await _fetch_messages(limit_scan=max(1, min(500, limit * 2)))
387
+
388
+ inbox_msgs = []
389
+ for m in msgs:
390
+ if len(inbox_msgs) >= limit:
391
+ break
392
+ # Check if user is in To or Cc
393
+ to_addrs = _addresses_from_field(m.get("To") or m.get("to"))
394
+ cc_addrs = _addresses_from_field(m.get("Cc") or m.get("cc"))
395
+ all_recipients = [a.lower() for a in to_addrs + cc_addrs]
396
+ if user_email_lower in all_recipients:
397
+ inbox_msgs.append(m)
398
+
399
+ return json.dumps(inbox_msgs, ensure_ascii=False)
400
+ except Exception as e:
401
+ return json.dumps({"error": str(e)})
402
+
403
+
404
+ @mcp.tool()
405
+ async def list_sent_messages(limit: int = 50) -> str:
406
+ """List sent emails - messages where you are the sender (From).
407
+
408
+ Args:
409
+ limit: Maximum number of messages to return (default: 50, max: 500)
410
+
411
+ Returns:
412
+ JSON array of sent messages (emails sent by the authenticated user)
413
+ """
414
+ try:
415
+ user_email = await _get_current_user_email()
416
+ if not user_email:
417
+ return json.dumps({"error": "Authentication required to list sent messages"})
418
+
419
+ user_email_lower = user_email.lower()
420
+ msgs = await _fetch_messages(limit_scan=max(1, min(500, limit * 2)))
421
+
422
+ sent_msgs = []
423
+ for m in msgs:
424
+ if len(sent_msgs) >= limit:
425
+ break
426
+ # Check if user is the sender
427
+ from_addrs = _addresses_from_field(m.get("From") or m.get("from"))
428
+ if user_email_lower in [a.lower() for a in from_addrs]:
429
+ sent_msgs.append(m)
430
+
431
+ return json.dumps(sent_msgs, ensure_ascii=False)
432
+ except Exception as e:
433
+ return json.dumps({"error": str(e)})
434
+
435
+
436
+ @mcp.tool()
437
+ async def get_message(id: str) -> str: # noqa: A002 - keep signature
438
+ """Get full details of a specific email by its ID.
439
+
440
+ Args:
441
+ id: The message ID (obtained from list_messages, search_messages, or find_message)
442
+
443
+ Returns:
444
+ JSON object with complete message details including headers, body, attachments, etc.
445
+ """
446
+ if not id:
447
+ return json.dumps({"error": "id is required"})
448
+ try:
449
+ detail = await _fetch_message_detail(id)
450
+ if not detail:
451
+ return json.dumps({"error": f"Message {id} not found"})
452
+ return json.dumps(detail, ensure_ascii=False)
453
+ except Exception as e:
454
+ return json.dumps({"error": str(e)})
455
+
456
+
457
+ @mcp.tool()
458
+ async def delete_all_messages() -> str:
459
+ """[DANGEROUS] Permanently delete ALL emails in Mailpit.
460
+ Only use when explicitly asked to 'delete all' or 'clear all emails'.
461
+ DO NOT use for listing, viewing, or searching emails."""
462
+ try:
463
+ client = await get_http()
464
+ r = await client.delete(MAILPIT_MESSAGES_API)
465
+ if r.status_code not in (200, 204):
466
+ return json.dumps({"error": r.text, "status": r.status_code})
467
+ return json.dumps({"ok": True})
468
+ except Exception as e:
469
+ return json.dumps({"error": str(e)})
470
+
471
+
472
+ @mcp.tool()
473
+ async def delete_message(id: str) -> str: # noqa: A002 - keep signature
474
+ """Delete a single email by id (idempotent).
475
+
476
+ Warning: Uses Mailpit batch-delete endpoint correctly to avoid deleting all messages.
477
+
478
+ Args:
479
+ id: Message id (REQUIRED)
480
+
481
+ Returns:
482
+ JSON {"ok": true, "id": id, "status": code} or error.
483
+ """
484
+ if not id:
485
+ return json.dumps({"error": "id is required"})
486
+ try:
487
+ client = await get_http()
488
+ r = await _delete_by_id_strict(client, id)
489
+ status = getattr(r, "status_code", 0)
490
+ if status in (200, 204, 404): # idempotent
491
+ return json.dumps({"ok": True, "id": id, "status": status})
492
+ return json.dumps({
493
+ "error": getattr(r, "text", "Delete failed"),
494
+ "status": status,
495
+ "id": id,
496
+ })
497
+ except Exception as e:
498
+ return json.dumps({"error": str(e)})
499
+
500
+
501
+ @mcp.tool()
502
+ async def find_message(subject_contains: Optional[str] = None,
503
+ from_contains: Optional[str] = None,
504
+ to_contains: Optional[str] = None,
505
+ limit: int = 100) -> str:
506
+ """Find the FIRST email matching the given criteria.
507
+ Returns a single message object or an error if not found.
508
+
509
+ Args:
510
+ subject_contains: Text to search in subject (case-insensitive)
511
+ from_contains: Text to search in sender address or name (case-insensitive)
512
+ to_contains: Text to search in recipient address or name (case-insensitive)
513
+ limit: Number of recent emails to scan through (default: 100, max: 1000).
514
+ This controls how many emails to check, NOT how many to return.
515
+ Increase this if the target email is older.
516
+
517
+ Returns:
518
+ JSON string containing the first matching message, or {"error": "No matching message found"}
519
+
520
+ Note: This function always returns at most ONE message. If you need multiple results, use search_messages instead.
521
+ """
522
+ try:
523
+ msgs = await _fetch_messages(limit_scan=max(1, min(1000, limit)))
524
+ subj_q = (subject_contains or "").lower()
525
+ from_q = (from_contains or "").lower()
526
+ to_q = (to_contains or "").lower()
527
+ for m in msgs:
528
+ subj = _as_text(m.get("Subject") or m.get("subject")).lower()
529
+ from_text = _as_text(m.get("From") or m.get("from"))
530
+ to_text = _as_text(m.get("To") or m.get("to"))
531
+ from_emails = " ".join(_addresses_from_field(m.get("From") or m.get("from")))
532
+ to_emails = " ".join(_addresses_from_field(m.get("To") or m.get("to")))
533
+
534
+ ok = True
535
+ if subj_q:
536
+ ok = ok and subj_q in subj
537
+ if from_q:
538
+ ok = ok and (from_q in from_emails.lower() or from_q in from_text.lower())
539
+ if to_q:
540
+ ok = ok and (to_q in to_emails.lower() or to_q in to_text.lower())
541
+ if ok:
542
+ return json.dumps(m, ensure_ascii=False)
543
+
544
+ for m in msgs:
545
+ mid = str(m.get("ID") or m.get("Id") or m.get("id") or "")
546
+ if not mid:
547
+ continue
548
+ detail = await _fetch_message_detail(mid)
549
+ if not detail:
550
+ continue
551
+ headers = _extract_headers(detail)
552
+ subj = (headers.get("Subject") or "").lower()
553
+
554
+ hdr_from_emails = _addresses_from_field(headers.get("From"))
555
+ hdr_to_emails = _addresses_from_field(headers.get("To"))
556
+ root_from_emails = _addresses_from_field(detail.get("From"))
557
+ root_to_emails = _addresses_from_field(detail.get("To"))
558
+
559
+ all_from = set([e.lower() for e in hdr_from_emails + root_from_emails])
560
+ all_to = set([e.lower() for e in hdr_to_emails + root_to_emails])
561
+
562
+ ok = True
563
+ if subj_q:
564
+ ok = ok and subj_q in subj
565
+ if from_q:
566
+ ok = ok and any(from_q in e for e in all_from)
567
+ if to_q:
568
+ ok = ok and any(to_q in e for e in all_to)
569
+ if ok:
570
+ return json.dumps(detail, ensure_ascii=False)
571
+
572
+ return json.dumps({"error": "No matching message found"})
573
+ except Exception as e:
574
+ return json.dumps({"error": str(e)})
575
+
576
+
577
+ @mcp.tool()
578
+ async def search_messages(subject_contains: Optional[str] = None,
579
+ from_contains: Optional[str] = None,
580
+ to_contains: Optional[str] = None,
581
+ body_contains: Optional[str] = None,
582
+ has_attachment: Optional[bool] = None,
583
+ limit: int = 50) -> str:
584
+ """Search for emails matching the given criteria and return ALL matching results (up to limit).
585
+
586
+ Args:
587
+ subject_contains: Text to search in subject (case-insensitive)
588
+ from_contains: Text to search in sender address or name (case-insensitive)
589
+ to_contains: Text to search in recipient address or name (case-insensitive)
590
+ body_contains: Text to search in email body (case-insensitive, slower as it fetches full content)
591
+ has_attachment: Filter by attachment presence (True/False)
592
+ limit: Maximum number of matching results to return (default: 50, max: 200).
593
+ This controls how many matching emails to return in the results.
594
+
595
+ Returns:
596
+ JSON array of matching messages (may be empty if no matches found)
597
+
598
+ Note: This function scans up to 1000 recent emails and returns multiple matches.
599
+ Use find_message if you only need the first match.
600
+ """
601
+ try:
602
+ msgs = await _fetch_messages(limit_scan=1000)
603
+ results: List[Dict[str, Any]] = []
604
+ subj_q = (subject_contains or "").lower()
605
+ from_q = (from_contains or "").lower()
606
+ to_q = (to_contains or "").lower()
607
+ body_q = (body_contains or "").lower()
608
+ for m in msgs:
609
+ if len(results) >= max(1, min(200, limit)):
610
+ break
611
+ subj = _as_text(m.get("Subject") or m.get("subject")).lower()
612
+ from_text = _as_text(m.get("From") or m.get("from"))
613
+ to_text = _as_text(m.get("To") or m.get("to"))
614
+ att_count = m.get("Attachments") or m.get("attachments")
615
+ ok = True
616
+ if subj_q:
617
+ ok = ok and subj_q in subj
618
+ if from_q:
619
+ ok = ok and from_q in from_text.lower()
620
+ if to_q:
621
+ ok = ok and to_q in to_text.lower()
622
+ if has_attachment is not None:
623
+ try:
624
+ ok = ok and bool(att_count) == bool(has_attachment)
625
+ except Exception:
626
+ ok = False
627
+ if not ok:
628
+ continue
629
+ if body_q:
630
+ mid = str(m.get("ID") or m.get("Id") or m.get("id") or "")
631
+ if not mid:
632
+ continue
633
+ detail = await _fetch_message_detail(mid)
634
+ body_text = _extract_body(detail).lower()
635
+ if body_q not in body_text:
636
+ continue
637
+ results.append(m)
638
+ return json.dumps(results[: max(1, min(200, limit))], ensure_ascii=False)
639
+ except Exception as e:
640
+ return json.dumps({"error": str(e)})
641
+
642
+
643
+ @mcp.tool()
644
+ async def get_message_body(id: str, prefer: Optional[str] = "auto") -> str:
645
+ """Get the body content of a specific email.
646
+
647
+ Args:
648
+ id: The message ID
649
+ prefer: Content format preference - "text" (plain text only), "html" (HTML only),
650
+ or "auto" (both formats, default)
651
+
652
+ Returns:
653
+ JSON object with "text" and/or "html" fields containing the email body
654
+ """
655
+ if not id:
656
+ return json.dumps({"error": "id is required"})
657
+ try:
658
+ detail = await _fetch_message_detail(id)
659
+ text = _pick(detail, "Text", "text") or ""
660
+ html = _pick(detail, "HTML", "html") or ""
661
+ if prefer == "text":
662
+ data = {"text": text}
663
+ elif prefer == "html":
664
+ data = {"html": html}
665
+ else:
666
+ data = {"text": text, "html": html}
667
+ return json.dumps(data, ensure_ascii=False)
668
+ except Exception as e:
669
+ return json.dumps({"error": str(e)})
670
+
671
+
672
+ @mcp.tool()
673
+ async def list_attachments(id: str) -> str:
674
+ """List all attachments in a specific email.
675
+
676
+ Args:
677
+ id: The message ID
678
+
679
+ Returns:
680
+ JSON array of attachment objects with filename, content type, and size information
681
+ """
682
+ if not id:
683
+ return json.dumps({"error": "id is required"})
684
+ try:
685
+ detail = await _fetch_message_detail(id)
686
+ atts = _extract_attachments(detail)
687
+ return json.dumps(atts, ensure_ascii=False)
688
+ except Exception as e:
689
+ return json.dumps({"error": str(e)})
690
+
691
+
692
+ @mcp.tool()
693
+ async def send_reply(id: str,
694
+ body: Optional[str] = None,
695
+ subject_prefix: Optional[str] = "Re:",
696
+ from_email: Optional[str] = None,
697
+ cc: Optional[str] = None,
698
+ bcc: Optional[str] = None) -> str:
699
+ """Reply to a specific email. The original message body will be included below your reply.
700
+
701
+ Args:
702
+ id: The message ID to reply to (REQUIRED - use search_messages or find_message to get this)
703
+ body: Your reply message content
704
+ subject_prefix: Prefix for reply subject (default: "Re:")
705
+ from_email: Sender address (must match your authenticated email, auto-filled if not provided)
706
+ cc: CC recipients (comma-separated)
707
+ bcc: BCC recipients (comma-separated)
708
+
709
+ Returns:
710
+ JSON object with send status
711
+
712
+ Security: You can only send from your own authenticated email address.
713
+ """
714
+ if not id:
715
+ return json.dumps({"error": "id is required"})
716
+ try:
717
+ detail = await _fetch_message_detail(id)
718
+ headers = _extract_headers(detail)
719
+ to_addr = headers.get("From") or ""
720
+ to_list = _addresses_from_field(to_addr)
721
+ if not to_list:
722
+ return json.dumps({"error": "original sender not found"})
723
+ subject = headers.get("Subject") or ""
724
+ reply_subj = f"{subject_prefix.strip()} {subject}".strip()
725
+ original = _extract_body(detail)
726
+ reply_body = (body or "").strip() + ("\n\n" + original if original else "")
727
+
728
+ # Verify sender identity
729
+ user_email = await _get_current_user_email()
730
+ if user_email:
731
+ if from_email and from_email.lower().strip() != user_email.lower().strip():
732
+ return json.dumps({
733
+ "error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
734
+ })
735
+ if not from_email:
736
+ from_email = user_email
737
+ elif from_email:
738
+ return json.dumps({
739
+ "error": "Authentication required: You must be authenticated to send emails"
740
+ })
741
+
742
+ result = await asyncio.to_thread(
743
+ _send_email_sync, to_list[0], reply_subj, reply_body, from_email, cc, bcc
744
+ )
745
+ return json.dumps(result, ensure_ascii=False)
746
+ except Exception as e:
747
+ return json.dumps({"error": str(e)})
748
+
749
+
750
+ @mcp.tool()
751
+ async def forward_message(id: str,
752
+ to: str,
753
+ subject_prefix: Optional[str] = "Fwd:",
754
+ from_email: Optional[str] = None) -> str:
755
+ """Forward a specific email to another recipient. Original headers and body will be included.
756
+
757
+ Args:
758
+ id: The message ID to forward (REQUIRED)
759
+ to: Recipient email address (REQUIRED)
760
+ subject_prefix: Prefix for forwarded subject (default: "Fwd:")
761
+ from_email: Sender address (must match your authenticated email, auto-filled if not provided)
762
+
763
+ Returns:
764
+ JSON object with send status
765
+
766
+ Security: You can only send from your own authenticated email address.
767
+ """
768
+ if not id or not to:
769
+ return json.dumps({"error": "id and to are required"})
770
+ try:
771
+ detail = await _fetch_message_detail(id)
772
+ headers = _extract_headers(detail)
773
+ subject = headers.get("Subject") or ""
774
+ fwd_subj = f"{subject_prefix.strip()} {subject}".strip()
775
+ hdrs = [f"{k}: {v}" for k, v in headers.items() if k in ("From", "To", "Date", "Subject")]
776
+ original = _extract_body(detail)
777
+ body = "\n".join(hdrs) + ("\n\n" + original if original else "")
778
+
779
+ # Verify sender identity
780
+ user_email = await _get_current_user_email()
781
+ if user_email:
782
+ if from_email and from_email.lower().strip() != user_email.lower().strip():
783
+ return json.dumps({
784
+ "error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
785
+ })
786
+ if not from_email:
787
+ from_email = user_email
788
+ elif from_email:
789
+ return json.dumps({
790
+ "error": "Authentication required: You must be authenticated to send emails"
791
+ })
792
+
793
+ result = await asyncio.to_thread(
794
+ _send_email_sync, to, fwd_subj, body, from_email, None, None
795
+ )
796
+ return json.dumps(result, ensure_ascii=False)
797
+ except Exception as e:
798
+ return json.dumps({"error": str(e)})
799
+
800
+
801
+ @mcp.tool()
802
+ async def batch_delete_messages(ids: List[str]) -> str:
803
+ """Delete multiple messages using Mailpit's batch delete API.
804
+
805
+ CRITICAL: Mailpit API behavior:
806
+ - DELETE /api/v1/messages with body {"IDs": ["id1", "id2"]} → deletes specified messages
807
+ - DELETE /api/v1/messages with NO body → deletes ALL messages!
808
+
809
+ We MUST ensure the JSON body is always sent with proper Content-Type header.
810
+ """
811
+ try:
812
+ client = await get_http()
813
+ valid_ids = [mid.strip() for mid in (ids or []) if mid and mid.strip()]
814
+
815
+ if not valid_ids:
816
+ return json.dumps({"error": "No valid IDs provided"})
817
+
818
+ # Mailpit batch delete: DELETE /api/v1/messages with {"IDs": ["id1", "id2", ...]}
819
+ # Note: uppercase "IDs" is required!
820
+ payload = {"IDs": valid_ids}
821
+
822
+ # httpx.delete() doesn't support json parameter, use request() instead
823
+ headers = {
824
+ "Content-Type": "application/json",
825
+ "Accept-Encoding": "identity",
826
+ }
827
+
828
+ r = await client.request(
829
+ "DELETE",
830
+ MAILPIT_MESSAGES_API,
831
+ content=json.dumps(payload),
832
+ headers=headers
833
+ )
834
+
835
+ if r.status_code in (200, 204):
836
+ return json.dumps({"deleted": valid_ids, "failed": []}, ensure_ascii=False)
837
+ elif r.status_code == 404:
838
+ # Idempotent: treat 404 as success (already deleted)
839
+ return json.dumps({"deleted": valid_ids, "failed": []}, ensure_ascii=False)
840
+ else:
841
+ return json.dumps({
842
+ "error": r.text,
843
+ "status": r.status_code,
844
+ "deleted": [],
845
+ "failed": [{"id": mid, "error": r.text} for mid in valid_ids]
846
+ })
847
+ except Exception as e:
848
+ return json.dumps({"error": str(e)})
849
+
850
+
851
+ def _normalize_recipients(value: Any) -> List[str]:
852
+ """Accept str (comma/semicolon separated) or list of str; return flat list of emails."""
853
+ parts: List[str] = []
854
+ if value is None:
855
+ return parts
856
+ if isinstance(value, list):
857
+ for v in value:
858
+ if isinstance(v, str) and v.strip():
859
+ parts.append(v)
860
+ elif isinstance(value, str):
861
+ # Split by comma/semicolon; also accept single address
862
+ for seg in value.replace(";", ",").split(","):
863
+ if seg.strip():
864
+ parts.append(seg.strip())
865
+ else:
866
+ parts.append(str(value))
867
+ # Parse into pure emails
868
+ emails = [addr for _, addr in getaddresses(parts) if addr]
869
+ # Fallback: if parsing removed everything, keep raw parts
870
+ return emails or parts
871
+
872
+
873
+ def _header_join(addresses: List[str]) -> str:
874
+ return ", ".join(addresses)
875
+
876
+
877
+ def _send_email_sync(to: Any,
878
+ subject: Optional[str],
879
+ body: Optional[str],
880
+ from_email: Optional[str],
881
+ cc: Optional[Any],
882
+ bcc: Optional[Any]) -> Dict[str, Any]:
883
+ sender = from_email or "noreply@example.com"
884
+ to_list = _normalize_recipients(to)
885
+ cc_list = _normalize_recipients(cc)
886
+ bcc_list = _normalize_recipients(bcc)
887
+
888
+ if not to_list:
889
+ raise ValueError("to is required")
890
+
891
+ msg = EmailMessage()
892
+ msg["From"] = sender
893
+ msg["To"] = _header_join(to_list)
894
+ if cc_list:
895
+ msg["Cc"] = _header_join(cc_list)
896
+ msg["Subject"] = subject or "Test Email"
897
+ msg.set_content(body or "This is a test email from Mailpit MCP.")
898
+
899
+ recipients: List[str] = []
900
+ recipients.extend(to_list)
901
+ recipients.extend(cc_list)
902
+ recipients.extend(bcc_list)
903
+
904
+ with smtplib.SMTP(MAILPIT_SMTP_HOST, MAILPIT_SMTP_PORT, local_hostname="localhost") as smtp:
905
+ smtp.send_message(msg, from_addr=sender, to_addrs=recipients)
906
+ return {"ok": True, "to": recipients, "from": sender, "subject": msg["Subject"]}
907
+
908
+
909
+ @mcp.tool()
910
+ async def send_email(to: Any,
911
+ subject: Optional[str] = None,
912
+ body: Optional[str] = None,
913
+ from_email: Optional[str] = None,
914
+ cc: Optional[Any] = None,
915
+ bcc: Optional[Any] = None) -> str:
916
+ """Send a new email message.
917
+
918
+ Args:
919
+ to: Recipient email address(es) - REQUIRED. Can be a single address or comma-separated list.
920
+ subject: Email subject line
921
+ body: Email body content (plain text)
922
+ from_email: Sender email address (must match your authenticated email, auto-filled if not provided)
923
+ cc: CC recipients (comma-separated or list)
924
+ bcc: BCC recipients (comma-separated or list)
925
+
926
+ Returns:
927
+ JSON object with send status including "ok", "to", "from", and "subject" fields
928
+
929
+ Security: You can only send from your own authenticated email address.
930
+ The from_email will be automatically set to your authenticated email if not provided.
931
+
932
+ Example:
933
+ send_email(to="bob@example.com", subject="Hello", body="Hi Bob!")
934
+ """
935
+ if to is None or (isinstance(to, str) and not to.strip()):
936
+ return json.dumps({"error": "to is required"})
937
+
938
+ try:
939
+ # Get current user's email if authenticated
940
+ user_email = await _get_current_user_email()
941
+
942
+ # If authenticated, enforce sender verification
943
+ if user_email:
944
+ # If from_email is provided, verify it matches user's email
945
+ if from_email:
946
+ if from_email.lower().strip() != user_email.lower().strip():
947
+ return json.dumps({
948
+ "error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
949
+ })
950
+ else:
951
+ # If no from_email provided, use user's email
952
+ from_email = user_email
953
+ elif from_email:
954
+ # If not authenticated but from_email is provided, reject for security
955
+ return json.dumps({
956
+ "error": "Authentication required: You must be authenticated to send emails"
957
+ })
958
+
959
+ result = await asyncio.to_thread(
960
+ _send_email_sync, to, subject, body, from_email, cc, bcc
961
+ )
962
+ return json.dumps(result, ensure_ascii=False)
963
+ except Exception as e:
964
+ return json.dumps({"error": str(e)})
965
+
966
+
967
+ def main():
968
+ import sys
969
+ print("Starting Gmail MCP server (Mailpit sandbox backend)...", file=sys.stderr)
970
+ sys.stderr.flush()
971
+
972
+ # Prefer explicit PORT env (for dynamic / per-task allocation), then
973
+ # fall back to static registry configuration.
974
+ env_port = os.getenv("PORT", "").strip()
975
+
976
+ def _port_from_registry(default_port: int) -> int:
977
+ try:
978
+ if yaml is None:
979
+ return default_port
980
+ registry_path = Path(__file__).resolve().parent.parent / "registry.yaml"
981
+ if not registry_path.exists():
982
+ return default_port
983
+ data = yaml.safe_load(registry_path.read_text()) or {}
984
+ service_name = Path(__file__).resolve().parent.name # 'gmail'
985
+ for srv in (data.get("servers") or []):
986
+ if isinstance(srv, dict) and srv.get("name") == service_name:
987
+ env = srv.get("env") or {}
988
+ port_str = str(env.get("PORT") or "").strip().strip('\"')
989
+ return int(port_str) if port_str else default_port
990
+ except Exception:
991
+ return default_port
992
+ return default_port
993
+
994
+ if env_port.isdigit():
995
+ port = int(env_port)
996
+ else:
997
+ # Prioritize registry configuration for port selection (default 8840)
998
+ port = _port_from_registry(8840)
999
+
1000
+ mcp.run(transport="http", host="0.0.0.0", port=port)
1001
+
1002
+
1003
+ if __name__ == "__main__":
1004
+ main()