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,2006 @@
1
+ import os
2
+ import sys
3
+ import asyncio
4
+ from typing import Any, Dict, List, Mapping, Optional, Union
5
+ from pathlib import Path
6
+ try:
7
+ import yaml # type: ignore
8
+ except Exception:
9
+ yaml = None
10
+
11
+ import httpx
12
+ from fastmcp import FastMCP
13
+
14
+
15
+ # Basic configuration
16
+ SALESFORCE_BASE_URL = os.getenv("SALESFORCE_BASE_URL", "http://128.111.28.87:8080/")
17
+ SALESFORCE_TOKEN_URL = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/access_token"
18
+
19
+ # Grant configuration (env fallbacks)
20
+ SALESFORCE_GRANT_TYPE = os.getenv("SALESFORCE_GRANT_TYPE", "password") # or "client_credentials"
21
+ SALESFORCE_CLIENT_ID = os.getenv("SALESFORCE_CLIENT_ID", "")
22
+ SALESFORCE_CLIENT_SECRET = os.getenv("SALESFORCE_CLIENT_SECRET", "")
23
+ SALESFORCE_USERNAME = os.getenv("SALESFORCE_USERNAME", "")
24
+ SALESFORCE_PASSWORD = os.getenv("SALESFORCE_PASSWORD", "")
25
+
26
+ # Optional pre-supplied access token
27
+ SALESFORCE_ACCESS_TOKEN = os.getenv("SALESFORCE_ACCESS_TOKEN", "")
28
+
29
+
30
+ mcp = FastMCP("Salesforce MCP Server")
31
+
32
+
33
+ def _bearer_headers(token: Optional[str]) -> Dict[str, str]:
34
+ headers: Dict[str, str] = {}
35
+ if token:
36
+ headers["Authorization"] = f"Bearer {token}"
37
+ return headers
38
+
39
+
40
+ # JSON:API header constants
41
+ JSONAPI_ACCEPT = "application/vnd.api+json"
42
+ JSONAPI_CONTENT = "application/vnd.api+json"
43
+
44
+
45
+ async def _request(
46
+ method: str,
47
+ url: str,
48
+ token: Optional[str] = None,
49
+ params: Optional[Dict[str, Any]] = None,
50
+ json: Optional[Any] = None,
51
+ data: Optional[Any] = None,
52
+ extra_headers: Optional[Dict[str, str]] = None,
53
+ ) -> Any:
54
+ """Unified HTTP request with structured error responses.
55
+
56
+ Returns parsed JSON on success; otherwise returns {error, status_code, text}.
57
+ """
58
+ headers = _bearer_headers(token)
59
+ if extra_headers:
60
+ headers.update(extra_headers)
61
+ try:
62
+ async with httpx.AsyncClient(timeout=30.0) as client:
63
+ r = await client.request(method, url, params=params, json=json, data=data, headers=headers)
64
+ # Try JSON first; if fails, fallback to text
65
+ r.raise_for_status()
66
+ try:
67
+ return r.json()
68
+ except Exception:
69
+ return {"ok": True, "text": r.text}
70
+ except httpx.HTTPStatusError as e:
71
+ resp = e.response
72
+ return {
73
+ "error": f"HTTP {resp.status_code}",
74
+ "status_code": resp.status_code,
75
+ "text": resp.text,
76
+ }
77
+ except httpx.RequestError as e:
78
+ return {"error": "request_error", "message": str(e)}
79
+
80
+
81
+ async def _get_token(access_token: Optional[str] = None) -> str:
82
+ """Get an access token. If not provided, attempt to obtain via configured grant."""
83
+ token = (access_token or SALESFORCE_ACCESS_TOKEN).strip()
84
+ if token:
85
+ return token
86
+ # Try to fetch a token using configured grant
87
+ return await _post_token()
88
+
89
+
90
+ # ------------------------------
91
+ # Internal HTTP helpers (non-tool)
92
+ # These are used by higher-level tool wrappers to avoid calling tool functions directly.
93
+ # ------------------------------
94
+
95
+ async def _http_get_available_modules(token: Optional[str] = None) -> Any:
96
+ tok = await _get_token(token)
97
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
98
+ return await _request("GET", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
99
+
100
+
101
+ async def _http_get_entry(module_name: str, record_id: str, fields: Optional[List[str]] = None, token: Optional[str] = None) -> Any:
102
+ tok = await _get_token(token)
103
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
104
+ params = _fields_param(module_name, fields)
105
+ return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
106
+
107
+
108
+ async def _http_list_records(module_name: str, filter: Optional[Dict[str, Any]] = None, sort: Optional[str] = None, page: int = 1, page_size: int = 20, fields: Optional[List[str]] = None, token: Optional[str] = None) -> Any:
109
+ tok = await _get_token(token)
110
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
111
+ params: Dict[str, Any] = {"page[number]": page or 1, "page[size]": page_size or 20}
112
+ if sort:
113
+ params["sort"] = sort
114
+ if fields:
115
+ params.update(_fields_param(module_name, fields))
116
+ if filter:
117
+ operator = str(filter.get("operator", "and"))
118
+ filt = dict(filter)
119
+ if "operator" in filt:
120
+ del filt["operator"]
121
+ params.update(_filters_params(operator, filt))
122
+ return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
123
+
124
+
125
+ async def _http_create_record(module_name: str, attributes: Dict[str, Any], relationships: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Any:
126
+ tok = await _get_token(token)
127
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
128
+ payload: Dict[str, Any] = {"data": {"type": module_name, "attributes": attributes}}
129
+ if relationships:
130
+ payload["data"]["relationships"] = relationships
131
+ return await _request("POST", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
132
+
133
+
134
+ async def _http_update_record(module_name: str, record_id: str, attributes: Dict[str, Any], relationships: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Any:
135
+ tok = await _get_token(token)
136
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
137
+ payload: Dict[str, Any] = {"data": {"type": module_name, "id": record_id, "attributes": attributes}}
138
+ if relationships:
139
+ payload["data"]["relationships"] = relationships
140
+ return await _request("PATCH", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
141
+
142
+
143
+ async def _http_delete_entry(module_name: str, record_id: str, token: Optional[str] = None) -> Any:
144
+ tok = await _get_token(token)
145
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
146
+ return await _request("DELETE", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
147
+
148
+
149
+ async def _http_get_relationships(module_name: str, record_id: str, relationship_name: str, page: int = 1, page_size: int = 20, token: Optional[str] = None) -> Any:
150
+ tok = await _get_token(token)
151
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{relationship_name}"
152
+ params = {"page[number]": page or 1, "page[size]": page_size or 20}
153
+ return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
154
+
155
+
156
+ async def _http_set_relationship(module_name: str, record_id: str, relationship_name: str, related_data: Union[List[Dict[str, str]], Dict[str, str]], token: Optional[str] = None) -> Any:
157
+ tok = await _get_token(token)
158
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{relationship_name}"
159
+ payload = {"data": related_data}
160
+ return await _request("POST", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
161
+
162
+
163
+ async def _http_get_module_fields(module_name: str, token: Optional[str] = None) -> Any:
164
+ tok = await _get_token(token)
165
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
166
+ return await _request("GET", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
167
+
168
+
169
+ async def _post_token(override_grant_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None) -> str:
170
+ """Obtain an OAuth2 access token from Salesforce.
171
+
172
+ Supports grant types: password, client_credentials.
173
+ Env fallbacks are used when parameters are not provided.
174
+ """
175
+ grant_type = (override_grant_type or SALESFORCE_GRANT_TYPE or "password").strip()
176
+ client_id = SALESFORCE_CLIENT_ID
177
+ client_secret = SALESFORCE_CLIENT_SECRET
178
+
179
+ data: Dict[str, str] = {"grant_type": grant_type}
180
+ if grant_type == "password":
181
+ data.update(
182
+ {
183
+ "username": username or SALESFORCE_USERNAME,
184
+ "password": password or SALESFORCE_PASSWORD,
185
+ "client_id": client_id,
186
+ "client_secret": client_secret,
187
+ }
188
+ )
189
+ elif grant_type == "client_credentials":
190
+ data.update(
191
+ {
192
+ "client_id": client_id,
193
+ "client_secret": client_secret,
194
+ }
195
+ )
196
+ else:
197
+ raise ValueError(f"Unsupported grant_type: {grant_type}")
198
+
199
+ # Token endpoint expects form data; be explicit about Accept
200
+ headers = {"Accept": "application/json"}
201
+ async with httpx.AsyncClient(timeout=30.0) as client:
202
+ r = await client.post(SALESFORCE_TOKEN_URL, data=data, headers=headers)
203
+ r.raise_for_status()
204
+ resp = r.json()
205
+ token = resp.get("access_token") or resp.get("token")
206
+ if not token:
207
+ raise RuntimeError(f"Failed to obtain access token: {resp}")
208
+ return token
209
+
210
+
211
+ def _resolve_token(access_token: Optional[str]) -> str:
212
+ token = (access_token or SALESFORCE_ACCESS_TOKEN).strip()
213
+ if token:
214
+ return token
215
+ # Fallback to env-driven token fetch (best-effort)
216
+ raise RuntimeError("Missing access token. Provide 'access_token' or configure SALESFORCE_* credentials for automatic fetch.")
217
+
218
+
219
+ def _fields_param(module_name: str, fields: Optional[List[str]]) -> Dict[str, str]:
220
+ if not fields:
221
+ return {}
222
+ return {f"fields[{module_name}]": ",".join(fields)}
223
+
224
+
225
+ def _filters_params(operator: str, filters: Optional[Mapping[str, Mapping[str, Any]]]) -> Dict[str, str]:
226
+ params: Dict[str, str] = {}
227
+ if operator:
228
+ params["filter[operator]"] = operator
229
+ if not filters:
230
+ return params
231
+ for field_name, ops in filters.items():
232
+ for cmp_op, value in ops.items():
233
+ params[f"filter[{field_name}][{cmp_op}]"] = str(value)
234
+ return params
235
+
236
+
237
+ # Note: Token acquisition can be handled automatically at startup if SALESFORCE_* credentials are provided.
238
+
239
+ @mcp.tool()
240
+ async def meta_modules() -> Any:
241
+ """List available Salesforce CRM modules for the authenticated user.
242
+
243
+ Returns:
244
+ JSON payload with list of available modules and their metadata.
245
+ """
246
+ token = await _get_token()
247
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
248
+ return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
249
+
250
+
251
+ @mcp.tool()
252
+ async def meta_fields(module_name: str) -> Any:
253
+ """List field metadata for a module. Use this to discover valid field names and types before creating or updating records.
254
+
255
+ Args:
256
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
257
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
258
+ Use meta_modules() to see the full list.
259
+
260
+ Returns:
261
+ JSON payload with field definitions including types, labels, and options.
262
+ """
263
+ token = await _get_token()
264
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
265
+ return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
266
+
267
+
268
+ @mcp.tool()
269
+ async def get_record(module_name: str, record_id: str, fields: Optional[List[str]] = None) -> Any:
270
+ """Get a single record by id.
271
+
272
+ Args:
273
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
274
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
275
+ Use meta_modules() to see the full list.
276
+ record_id: Record GUID.
277
+ fields: Optional list of field names to include in response.
278
+
279
+ Returns:
280
+ JSON:API response with record data including id, type, and attributes.
281
+ """
282
+ token = await _get_token()
283
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
284
+ params: Dict[str, str] = _fields_param(module_name, fields)
285
+ return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
286
+
287
+
288
+ @mcp.tool()
289
+ async def list_records(
290
+ module_name: str,
291
+ fields: Optional[List[str]] = None,
292
+ page_number: int = 1,
293
+ page_size: int = 20,
294
+ sort: Optional[str] = None,
295
+ operator: str = "and",
296
+ filters: Optional[Mapping[str, Mapping[str, Any]]] = None,
297
+ ) -> Any:
298
+ """List records for a module with pagination, fields, sort and filters.
299
+
300
+ Args:
301
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
302
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
303
+ Use meta_modules() to see the full list.
304
+ fields: Optional list of fields to include in response.
305
+ page_number: Page index (1-based).
306
+ page_size: Page size (default 20).
307
+ sort: Comma-separated field list with optional '-' prefix for desc.
308
+ operator: Logical operator for filters ("and"/"or").
309
+ filters: JSON:API filter mapping, e.g., {"name": {"eq": "Acme"}}.
310
+
311
+ Returns:
312
+ Paginated list of records with id, type, and attributes.
313
+ """
314
+ try:
315
+ token = await _get_token()
316
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
317
+ params: Dict[str, Any] = {
318
+ "page[number]": page_number,
319
+ "page[size]": page_size,
320
+ }
321
+ if sort:
322
+ params["sort"] = sort
323
+ if fields:
324
+ params.update(_fields_param(module_name, fields))
325
+ if filters:
326
+ params.update(_filters_params(operator, filters))
327
+ return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
328
+ except Exception as e:
329
+ return {"error": True, "exception": type(e).__name__, "message": str(e)}
330
+
331
+
332
+ @mcp.tool()
333
+ async def list_leads(
334
+ page: Optional[int] = 1,
335
+ page_size: Optional[int] = 20,
336
+ ) -> Any:
337
+ """List all leads in the CRM system.
338
+
339
+ Args:
340
+ page: Index of the target page to retrieve (1-based, default 1).
341
+ page_size: Number of records per page to segment the lead list (default 20).
342
+
343
+ Returns:
344
+ List of lead records with id, type, and attributes (first_name, last_name,
345
+ account_name, phone_work, email1, date_entered).
346
+ """
347
+ return await _http_list_records(
348
+ "Leads",
349
+ filter=None,
350
+ sort="-date_entered",
351
+ page=page or 1,
352
+ page_size=page_size or 20,
353
+ fields=["first_name", "last_name", "account_name", "phone_work", "email1", "status", "date_entered"],
354
+ )
355
+
356
+
357
+
358
+ @mcp.tool()
359
+ async def create_record(module_name: str, attributes: Dict[str, Any]) -> Any:
360
+ """Create a CRM record for any module. This is the generic record creation tool.
361
+
362
+ Args:
363
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
364
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
365
+ Use meta_modules() to see the full list.
366
+ attributes: Dictionary of field names and values. Common attributes per module:
367
+ - Notes: {"name": "title", "description": "body text", "contact_id": "GUID",
368
+ "parent_type": "Accounts", "parent_id": "GUID", "assigned_user_id": "GUID"}
369
+ - Tasks: {"name": "subject", "status": "Not Started", "priority": "High",
370
+ "date_due": "YYYY-MM-DD HH:MM:SS", "description": "details",
371
+ "contact_id": "GUID", "parent_type": "Accounts", "parent_id": "GUID"}
372
+ - Meetings: {"name": "subject", "status": "Planned|Held|Not Held",
373
+ "date_start": "YYYY-MM-DD HH:MM:SS", "duration_hours": 1,
374
+ "duration_minutes": 0, "description": "notes", "location": "place",
375
+ "parent_type": "Accounts", "parent_id": "GUID"}
376
+ - Calls: {"name": "subject", "direction": "Inbound|Outbound",
377
+ "status": "Planned|Held", "date_start": "YYYY-MM-DD HH:MM:SS",
378
+ "duration_hours": 0, "duration_minutes": 30, "description": "notes",
379
+ "parent_type": "Accounts", "parent_id": "GUID"}
380
+ - Accounts: {"name": "account name", "industry": "Technology",
381
+ "billing_address_city": "city", "phone_office": "number"}
382
+ - Contacts: {"first_name": "name", "last_name": "name",
383
+ "email1": "email", "phone_work": "number", "account_id": "GUID"}
384
+ - Leads: {"first_name": "name", "last_name": "name",
385
+ "email1": "email", "status": "New", "account_name": "company"}
386
+ - Opportunities: {"name": "deal name", "amount": 10000,
387
+ "sales_stage": "Prospecting", "date_closed": "YYYY-MM-DD",
388
+ "account_id": "GUID"}
389
+ - Cases: {"name": "case title", "status": "Open_New",
390
+ "priority": "P1|P2|P3", "description": "details",
391
+ "account_id": "GUID", "contact_id": "GUID"}
392
+ Use meta_fields(module_name) to discover all valid fields for a module.
393
+
394
+ Returns:
395
+ JSON:API response with created record data including generated id.
396
+ """
397
+ token = await _get_token()
398
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
399
+ payload = {"data": {"type": module_name, "attributes": attributes}}
400
+ return await _request(
401
+ "POST",
402
+ url,
403
+ token,
404
+ json=payload,
405
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
406
+ )
407
+
408
+
409
+ @mcp.tool()
410
+ async def update_record(module_name: str, record_id: str, attributes: Dict[str, Any]) -> Any:
411
+ """Update an existing CRM record by id (partial update).
412
+
413
+ Args:
414
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
415
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
416
+ Use meta_modules() to see the full list.
417
+ record_id: Record GUID to update.
418
+ attributes: Dictionary of field names and values to update. Only include fields
419
+ you want to change. See create_record() for common attributes per module.
420
+
421
+ Returns:
422
+ JSON:API response with updated record data.
423
+ """
424
+ token = await _get_token()
425
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
426
+ payload = {"data": {"type": module_name, "id": record_id, "attributes": attributes}}
427
+ return await _request(
428
+ "PATCH",
429
+ url,
430
+ token,
431
+ json=payload,
432
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
433
+ )
434
+
435
+
436
+ @mcp.tool()
437
+ async def delete_record(module_name: str, record_id: str) -> Any:
438
+ """Delete a Salesforce CRM record by id.
439
+
440
+ Args:
441
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
442
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
443
+ Use meta_modules() to see the full list.
444
+ record_id: Record GUID to delete.
445
+
446
+ Returns:
447
+ Success confirmation or error details.
448
+ """
449
+ token = await _get_token()
450
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
451
+ resp = await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
452
+ # Keep consistent success envelope if server returns empty body
453
+ if isinstance(resp, dict) and resp.get("error"):
454
+ return resp
455
+ return resp or {"ok": True, "id": record_id}
456
+
457
+
458
+ @mcp.tool()
459
+ async def relationship_get(module_name: str, record_id: str, link_field_name: str) -> Any:
460
+ """Get related records for a given record's relationship field.
461
+
462
+ Args:
463
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
464
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
465
+ Use meta_modules() to see the full list.
466
+ record_id: Record GUID.
467
+ link_field_name: Relationship link name (e.g., "contacts", "opportunities", "notes", "tasks", "meetings", "calls").
468
+
469
+ Returns:
470
+ JSON:API response with list of related records.
471
+ """
472
+ token = await _get_token()
473
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}"
474
+ return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
475
+
476
+
477
+ @mcp.tool()
478
+ async def relationship_create(
479
+ module_name: str,
480
+ record_id: str,
481
+ link_field_name: str,
482
+ related_module_name: str,
483
+ related_record_id: str,
484
+ ) -> Any:
485
+ """Create a relationship link from a record to another record.
486
+
487
+ Args:
488
+ module_name: Source CRM module name. Available modules: "Accounts", "Contacts", "Leads",
489
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
490
+ Use meta_modules() to see the full list.
491
+ record_id: Source record GUID.
492
+ link_field_name: Relationship link field name on source (e.g., "contacts", "opportunities", "notes", "tasks").
493
+ related_module_name: Target module name (e.g., "Contacts", "Notes", "Tasks").
494
+ related_record_id: Target record GUID.
495
+
496
+ Returns:
497
+ JSON:API response confirming the relationship creation.
498
+ """
499
+ token = await _get_token()
500
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}"
501
+ payload = {"data": {"type": related_module_name, "id": related_record_id}}
502
+ return await _request(
503
+ "POST",
504
+ url,
505
+ token,
506
+ json=payload,
507
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
508
+ )
509
+
510
+
511
+ @mcp.tool()
512
+ async def relationship_delete(
513
+ module_name: str,
514
+ record_id: str,
515
+ link_field_name: str,
516
+ related_record_id: str,
517
+ ) -> Any:
518
+ """Remove a relationship between records.
519
+
520
+ Args:
521
+ module_name: Source CRM module name. Available modules: "Accounts", "Contacts", "Leads",
522
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
523
+ Use meta_modules() to see the full list.
524
+ record_id: Source record GUID.
525
+ link_field_name: Relationship link field name on source (e.g., "contacts", "opportunities", "notes", "tasks").
526
+ related_record_id: Target record GUID to unlink.
527
+
528
+ Returns:
529
+ Success confirmation or error details.
530
+ """
531
+ token = await _get_token()
532
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}/{related_record_id}"
533
+ resp = await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
534
+ if isinstance(resp, dict) and resp.get("error"):
535
+ return resp
536
+ return resp or {"ok": True}
537
+
538
+
539
+ @mcp.tool()
540
+ async def logout() -> Any:
541
+ """Invalidate the current session token (server-side logout).
542
+
543
+ Returns:
544
+ Success confirmation or error details.
545
+ """
546
+ token = await _get_token()
547
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/logout"
548
+ resp = await _request("POST", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
549
+ if isinstance(resp, dict) and resp.get("error"):
550
+ return resp
551
+ return resp or {"ok": True}
552
+
553
+
554
+ @mcp.tool()
555
+ async def health() -> Any:
556
+ """Health check: probe root path, meta/modules, and access_token endpoints.
557
+
558
+ Returns:
559
+ Dictionary with status of root, meta_modules, and access_token endpoints.
560
+ """
561
+ base = SALESFORCE_BASE_URL.rstrip("/")
562
+ results: Dict[str, Any] = {}
563
+ # Root
564
+ try:
565
+ async with httpx.AsyncClient(timeout=10.0) as client:
566
+ r = await client.get(f"{base}/")
567
+ results["root"] = {"status": r.status_code}
568
+ except Exception as e:
569
+ results["root"] = {"error": str(e)}
570
+
571
+ # meta/modules (expect 401/403 when unauthenticated; 500 indicates server error)
572
+ res_meta = await _request("GET", f"{base}/Api/V8/meta/modules", None, extra_headers={"Accept": JSONAPI_ACCEPT})
573
+ results["meta_modules"] = res_meta if isinstance(res_meta, dict) else {"ok": True}
574
+
575
+ # access_token (probing with invalid credentials should return 400/401; 500 indicates server error)
576
+ try:
577
+ async with httpx.AsyncClient(timeout=10.0) as client:
578
+ r = await client.post(f"{base}/Api/access_token", data={"grant_type": "client_credentials", "client_id": "x", "client_secret": "y"}, headers={"Accept": "application/json"})
579
+ results["access_token"] = {"status": r.status_code, "text": (r.text[:200] if r.text else "")}
580
+ except Exception as e:
581
+ results["access_token"] = {"error": str(e)}
582
+
583
+ return results
584
+
585
+
586
+ async def _ensure_token_on_startup() -> None:
587
+ """Try to acquire SALESFORCE_ACCESS_TOKEN using configured credentials (non-blocking)."""
588
+ token = (os.getenv("SALESFORCE_ACCESS_TOKEN") or "").strip()
589
+ if token:
590
+ return
591
+ try:
592
+ fetched = await _post_token()
593
+ os.environ["SALESFORCE_ACCESS_TOKEN"] = fetched
594
+ print("Salesforce access token acquired on startup.", file=sys.stderr)
595
+ except Exception as e:
596
+ # Do not block server; tools will attempt token acquisition when called.
597
+ print(f"Salesforce token acquisition skipped on startup: {e}", file=sys.stderr)
598
+
599
+
600
+ def main() -> None:
601
+ print("Starting Salesforce MCP Server...", file=sys.stderr)
602
+ sys.stderr.flush()
603
+ try:
604
+ asyncio.run(_ensure_token_on_startup())
605
+ except RuntimeError:
606
+ pass
607
+ host = os.getenv("SALESFORCE_MCP_HOST", "localhost")
608
+ def _port_from_registry(default_port: int) -> int:
609
+ try:
610
+ if yaml is None:
611
+ return default_port
612
+ registry_path = Path(__file__).resolve().parent.parent / "registry.yaml"
613
+ if not registry_path.exists():
614
+ return default_port
615
+ data = yaml.safe_load(registry_path.read_text()) or {}
616
+ service_name = Path(__file__).resolve().parent.name # 'salesforce'
617
+ for srv in (data.get("servers") or []):
618
+ if isinstance(srv, dict) and srv.get("name") == service_name:
619
+ env = srv.get("env") or {}
620
+ port_str = str(env.get("PORT") or "").strip().strip('\"')
621
+ return int(port_str) if port_str else default_port
622
+ except Exception:
623
+ return default_port
624
+ return default_port
625
+ # Prefer PORT env var, then registry, then default
626
+ env_port = os.getenv("PORT", "").strip()
627
+ if env_port.isdigit():
628
+ port = int(env_port)
629
+ else:
630
+ port = _port_from_registry(8845)
631
+ mcp.run(transport="http", host=host, port=port)
632
+
633
+ @mcp.tool()
634
+ async def get_available_modules() -> Any:
635
+ """List available modules for the authenticated user.
636
+
637
+ Returns:
638
+ JSON payload with list of available modules and their metadata.
639
+ """
640
+ token = await _get_token()
641
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
642
+ return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
643
+
644
+
645
+ @mcp.tool()
646
+ async def get_entry_list(
647
+ module_name: str,
648
+ filter: Optional[Dict[str, Any]] = None,
649
+ sort: Optional[str] = None,
650
+ page: Optional[int] = 1,
651
+ page_size: Optional[int] = 20,
652
+ fields: Optional[List[str]] = None,
653
+ ) -> Any:
654
+ """Get a list of records from a specific module with optional filtering and pagination.
655
+
656
+ Args:
657
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
658
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
659
+ Use meta_modules() to see the full list.
660
+ filter: Filter object with field conditions (e.g., {"name": {"eq": "Acme"}}).
661
+ sort: Comma-separated field list with optional '-' prefix for descending.
662
+ page: Page number (1-based, default 1).
663
+ page_size: Number of records per page (default 20).
664
+ fields: Optional list of field names to include in response.
665
+
666
+ Returns:
667
+ JSON:API response with list of records and pagination metadata.
668
+ """
669
+ token = await _get_token()
670
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
671
+ params: Dict[str, Any] = {
672
+ "page[number]": page or 1,
673
+ "page[size]": page_size or 20,
674
+ }
675
+ if sort:
676
+ params["sort"] = sort
677
+ if fields:
678
+ params.update(_fields_param(module_name, fields))
679
+ # Translate filter object: supports embedded operator and field ops
680
+ if filter:
681
+ operator = str(filter.get("operator", "and"))
682
+ filt = dict(filter)
683
+ if "operator" in filt:
684
+ del filt["operator"]
685
+ params.update(_filters_params(operator, filt))
686
+ return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
687
+
688
+
689
+ @mcp.tool()
690
+ async def get_entry(module_name: str, id: str, fields: Optional[List[str]] = None) -> Any:
691
+ """Get a single entry by id.
692
+
693
+ Args:
694
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
695
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
696
+ Use meta_modules() to see the full list.
697
+ id: Record GUID.
698
+ fields: Optional list of field names to include in response.
699
+
700
+ Returns:
701
+ JSON:API response with record data including id, type, and attributes.
702
+ """
703
+ token = await _get_token()
704
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}"
705
+ params = _fields_param(module_name, fields)
706
+ return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
707
+
708
+
709
+ @mcp.tool()
710
+ async def set_entry(
711
+ module_name: str,
712
+ attributes: Dict[str, Any],
713
+ relationships: Optional[Dict[str, Any]] = None,
714
+ ) -> Any:
715
+ """Create a new record in any CRM module.
716
+
717
+ Args:
718
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
719
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
720
+ Use meta_modules() to see the full list.
721
+ attributes: Dictionary of field names and values. Common attributes per module:
722
+ - Notes: {"name": "title", "description": "body text", "contact_id": "GUID",
723
+ "parent_type": "Accounts", "parent_id": "GUID", "assigned_user_id": "GUID"}
724
+ - Tasks: {"name": "subject", "status": "Not Started", "priority": "High",
725
+ "date_due": "YYYY-MM-DD HH:MM:SS", "description": "details",
726
+ "contact_id": "GUID", "parent_type": "Accounts", "parent_id": "GUID"}
727
+ - Meetings: {"name": "subject", "status": "Planned|Held|Not Held",
728
+ "date_start": "YYYY-MM-DD HH:MM:SS", "duration_hours": 1,
729
+ "duration_minutes": 0, "description": "notes", "location": "place",
730
+ "parent_type": "Accounts", "parent_id": "GUID"}
731
+ - Calls: {"name": "subject", "direction": "Inbound|Outbound",
732
+ "status": "Planned|Held", "date_start": "YYYY-MM-DD HH:MM:SS",
733
+ "duration_hours": 0, "duration_minutes": 30, "description": "notes",
734
+ "parent_type": "Accounts", "parent_id": "GUID"}
735
+ Use meta_fields(module_name) to discover all valid fields for a module.
736
+ relationships: Optional dictionary of relationship links.
737
+
738
+ Returns:
739
+ JSON:API response with created record data including generated id.
740
+ """
741
+ token = await _get_token()
742
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
743
+ payload: Dict[str, Any] = {"data": {"type": module_name, "attributes": attributes}}
744
+ if relationships:
745
+ payload["data"]["relationships"] = relationships
746
+ return await _request(
747
+ "POST",
748
+ url,
749
+ token,
750
+ json=payload,
751
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
752
+ )
753
+
754
+
755
+ @mcp.tool()
756
+ async def update_entry(
757
+ module_name: str,
758
+ id: str,
759
+ attributes: Dict[str, Any],
760
+ relationships: Optional[Dict[str, Any]] = None,
761
+ ) -> Any:
762
+ """Update an existing record in any CRM module (partial update).
763
+
764
+ Args:
765
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
766
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
767
+ Use meta_modules() to see the full list.
768
+ id: Record GUID to update.
769
+ attributes: Dictionary of field names and values to update. Only include fields
770
+ you want to change. See set_entry() or create_record() for common attributes per module.
771
+ relationships: Optional dictionary of relationship links to update.
772
+
773
+ Returns:
774
+ JSON:API response with updated record data.
775
+ """
776
+ token = await _get_token()
777
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
778
+ payload: Dict[str, Any] = {"data": {"type": module_name, "id": id, "attributes": attributes}}
779
+ if relationships:
780
+ payload["data"]["relationships"] = relationships
781
+ return await _request(
782
+ "PATCH",
783
+ url,
784
+ token,
785
+ json=payload,
786
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
787
+ )
788
+
789
+
790
+ @mcp.tool()
791
+ async def delete_entry(module_name: str, id: str) -> Any:
792
+ """Delete an entry by id.
793
+
794
+ Args:
795
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
796
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
797
+ Use meta_modules() to see the full list.
798
+ id: Record GUID to delete.
799
+
800
+ Returns:
801
+ Success confirmation or error details.
802
+ """
803
+ token = await _get_token()
804
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}"
805
+ return await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
806
+
807
+
808
+ @mcp.tool()
809
+ async def get_relationships(
810
+ module_name: str,
811
+ id: str,
812
+ relationship_name: str,
813
+ page: Optional[int] = 1,
814
+ page_size: Optional[int] = 20,
815
+ ) -> Any:
816
+ """List related records for an entry with pagination.
817
+
818
+ Args:
819
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
820
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
821
+ Use meta_modules() to see the full list.
822
+ id: Record GUID.
823
+ relationship_name: Link/relationship name (e.g., "contacts", "opportunities", "notes", "tasks", "meetings", "calls").
824
+ page: Page number (1-based, default 1).
825
+ page_size: Number of records per page (default 20).
826
+
827
+ Returns:
828
+ JSON:API response with list of related records.
829
+ """
830
+ token = await _get_token()
831
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}/relationships/{relationship_name}"
832
+ params = {"page[number]": page or 1, "page[size]": page_size or 20}
833
+ return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
834
+
835
+
836
+ @mcp.tool()
837
+ async def set_relationship(
838
+ module_name: str,
839
+ id: str,
840
+ relationship_name: str,
841
+ related_data: Dict[str, str],
842
+ ) -> Any:
843
+ """Set (replace or add) relationships for a record.
844
+
845
+ Args:
846
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
847
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
848
+ Use meta_modules() to see the full list.
849
+ id: Record GUID.
850
+ relationship_name: Relationship link name (e.g., "contacts", "opportunities", "notes", "tasks").
851
+ related_data: Linkage object with 'type' and 'id' keys.
852
+
853
+ Returns:
854
+ JSON:API response confirming the relationship update.
855
+ """
856
+ token = await _get_token()
857
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}/relationships/{relationship_name}"
858
+ payload = {"data": related_data}
859
+ return await _request(
860
+ "POST",
861
+ url,
862
+ token,
863
+ json=payload,
864
+ extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
865
+ )
866
+
867
+
868
+ @mcp.tool()
869
+ async def get_module_fields(module_name: str) -> Any:
870
+ """List field metadata for a module. Use this to discover all valid field names and types before creating or updating records.
871
+
872
+ Args:
873
+ module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
874
+ "Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
875
+ Use meta_modules() to see the full list.
876
+
877
+ Returns:
878
+ JSON payload with field definitions including types, labels, and options.
879
+ """
880
+ token = await _get_token()
881
+ url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
882
+ return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
883
+
884
+
885
+ # ============ Lead-specific tools ============
886
+
887
+ def _sanitize_attributes(attributes: Dict[str, Any]) -> Dict[str, Any]:
888
+ excluded = {"id", "lead_id", "date_entered", "date_modified", "modified_user_id", "created_by", "created_by_name"}
889
+ out: Dict[str, Any] = {}
890
+ for k, v in attributes.items():
891
+ if k in excluded:
892
+ continue
893
+ if isinstance(v, (str, int, float, bool)) or v is None:
894
+ out[k] = v
895
+ return out
896
+
897
+
898
+ async def _get_field_options(module_name: str, field_name: str) -> List[str]:
899
+ resp = await _http_get_module_fields(module_name)
900
+ # Try common shapes
901
+ options: List[str] = []
902
+ if isinstance(resp, dict):
903
+ data = resp.get("data") or resp
904
+ attrs = None
905
+ if isinstance(data, dict):
906
+ attrs = data.get("attributes") or data.get("fields")
907
+ fields_obj = None
908
+ if attrs and isinstance(attrs, dict):
909
+ fields_obj = attrs.get("fields") or attrs
910
+ if fields_obj and isinstance(fields_obj, dict):
911
+ field = fields_obj.get(field_name)
912
+ if isinstance(field, dict):
913
+ opts = field.get("options")
914
+ if isinstance(opts, dict):
915
+ options = list(opts.keys())
916
+ return options
917
+
918
+
919
+ @mcp.tool()
920
+ async def create_lead(
921
+ first_name: str,
922
+ last_name: str,
923
+ title: Optional[str] = None,
924
+ account_name: Optional[str] = None,
925
+ email: Optional[str] = None,
926
+ phone_work: Optional[str] = None,
927
+ phone_mobile: Optional[str] = None,
928
+ lead_source: Optional[str] = None,
929
+ status: Optional[str] = None,
930
+ description: Optional[str] = None,
931
+ assigned_user_id: Optional[str] = None,
932
+ ) -> Any:
933
+ """Create a new Lead record.
934
+
935
+ Args:
936
+ first_name: Lead's first name (required).
937
+ last_name: Lead's last name (required).
938
+ title: Job title.
939
+ account_name: Company/account name.
940
+ email: Primary email address.
941
+ phone_work: Work phone number.
942
+ phone_mobile: Mobile phone number.
943
+ lead_source: Source of the lead (e.g., "Web", "Cold Call", "Referral").
944
+ status: Lead status (e.g., "New", "Assigned"). Defaults to "New".
945
+ description: Additional notes or description.
946
+ assigned_user_id: User GUID to assign the lead to.
947
+
948
+ Returns:
949
+ JSON:API response with the newly created lead record.
950
+ """
951
+ attributes: Dict[str, Any] = {
952
+ "first_name": first_name,
953
+ "last_name": last_name,
954
+ }
955
+ if status:
956
+ attributes["status"] = status
957
+ else:
958
+ attributes["status"] = "New"
959
+ if lead_source:
960
+ attributes["lead_source"] = lead_source
961
+ if title:
962
+ attributes["title"] = title
963
+ if account_name:
964
+ attributes["account_name"] = account_name
965
+ if email:
966
+ attributes["email1"] = email
967
+ if phone_work:
968
+ attributes["phone_work"] = phone_work
969
+ if phone_mobile:
970
+ attributes["phone_mobile"] = phone_mobile
971
+ if description:
972
+ attributes["description"] = description
973
+ if assigned_user_id:
974
+ attributes["assigned_user_id"] = assigned_user_id
975
+
976
+ # Note: Salesforce Leads module doesn't support JSON:API relationships in create requests,
977
+ # so we pass assigned_user_id as an attribute instead
978
+ return await _http_create_record("Leads", attributes)
979
+
980
+
981
+ @mcp.tool()
982
+ async def search_leads(
983
+ search_term: Optional[str] = None,
984
+ first_name: Optional[str] = None,
985
+ last_name: Optional[str] = None,
986
+ status: Optional[str] = None,
987
+ lead_source: Optional[str] = None,
988
+ assigned_user_id: Optional[str] = None,
989
+ created_after: Optional[str] = None,
990
+ created_before: Optional[str] = None,
991
+ page: Optional[int] = 1,
992
+ page_size: Optional[int] = 20,
993
+ search_operator: Optional[str] = "or",
994
+ ) -> Any:
995
+ """Search leads by person name, status, source, assignee, and date range.
996
+
997
+ Args:
998
+ search_term: Person name search in "FirstName LastName" format (e.g., "Robert Kim").
999
+ - Multi-word: First word matches first_name, last word matches last_name (AND).
1000
+ - Single word: Searches across first_name, last_name, account_name (OR).
1001
+ For more precise control, use first_name and last_name parameters instead.
1002
+ first_name: Filter by first name (partial match with LIKE).
1003
+ last_name: Filter by last name (partial match with LIKE).
1004
+ status: Lead status code (e.g., "New", "Assigned", "Converted").
1005
+ lead_source: Source code (e.g., "Web", "Cold Call", "Referral").
1006
+ assigned_user_id: Filter by assigned user GUID.
1007
+ created_after: ISO date string (YYYY-MM-DD) for minimum creation date.
1008
+ created_before: ISO date string (YYYY-MM-DD) for maximum creation date.
1009
+ page: Index of the target page to retrieve (1-based, default 1).
1010
+ page_size: Number of records per page to segment the lead list (default 20).
1011
+ search_operator: Logical operator for combining filters ("or" or "and", default "or").
1012
+ Note: When search_term has multiple words, "and" is used automatically for name matching.
1013
+
1014
+ Returns:
1015
+ JSON:API response with matching lead records.
1016
+ """
1017
+ filters: Dict[str, Any] = {}
1018
+
1019
+ # Handle search_term - intelligently parse multi-word searches
1020
+ if search_term:
1021
+ words = search_term.strip().split()
1022
+ if len(words) >= 2:
1023
+ # Multi-word search: assume "first_name last_name" format
1024
+ # Use AND to match both parts
1025
+ filters["operator"] = "and"
1026
+ filters["first_name"] = {"like": f"%{words[0]}%"}
1027
+ filters["last_name"] = {"like": f"%{words[-1]}%"}
1028
+ else:
1029
+ # Single word: search across all name fields with OR
1030
+ filters["operator"] = "or"
1031
+ like = f"%{search_term}%"
1032
+ filters["first_name"] = {"like": like}
1033
+ filters["last_name"] = {"like": like}
1034
+ filters["account_name"] = {"like": like}
1035
+ else:
1036
+ # No search_term - use explicit first_name/last_name if provided
1037
+ filters["operator"] = search_operator if search_operator in ("or", "and") else "and"
1038
+ if first_name:
1039
+ filters["first_name"] = {"like": f"%{first_name}%"}
1040
+ if last_name:
1041
+ filters["last_name"] = {"like": f"%{last_name}%"}
1042
+
1043
+ # Add other filters
1044
+ if status:
1045
+ filters["status"] = {"eq": status}
1046
+ if lead_source:
1047
+ filters["lead_source"] = {"eq": lead_source}
1048
+ if assigned_user_id:
1049
+ filters["assigned_user_id"] = {"eq": assigned_user_id}
1050
+ if created_after or created_before:
1051
+ filters["date_entered"] = {}
1052
+ if created_after:
1053
+ filters["date_entered"]["gt"] = created_after
1054
+ if created_before:
1055
+ filters["date_entered"]["lt"] = created_before
1056
+
1057
+ return await _http_list_records("Leads", filters, "-date_entered", page or 1, page_size or 20, ["first_name", "last_name", "account_name", "phone_work", "phone_mobile", "status"]) # type: ignore[arg-type]
1058
+
1059
+
1060
+ @mcp.tool()
1061
+ async def update_lead_status(lead_id: str, status: str, description: Optional[str] = None) -> Any:
1062
+ """Update a lead's status and optional description.
1063
+
1064
+ Args:
1065
+ lead_id: Lead record GUID.
1066
+ status: New status value (e.g., "New", "Assigned", "In Process", "Converted").
1067
+ description: Optional description text to update.
1068
+
1069
+ Returns:
1070
+ JSON:API response with updated lead record.
1071
+ """
1072
+ attributes: Dict[str, Any] = {"status": status}
1073
+ if description is not None:
1074
+ attributes["description"] = description
1075
+ return await _http_update_record("Leads", lead_id, attributes)
1076
+
1077
+
1078
+ @mcp.tool()
1079
+ async def assign_lead(lead_id: str, user_id: str, send_notification: Optional[bool] = True) -> Any: # noqa: ARG002
1080
+ """Assign a lead to a user.
1081
+
1082
+ Args:
1083
+ lead_id: Lead record GUID.
1084
+ user_id: User GUID to assign the lead to.
1085
+ send_notification: Whether to send notification (not implemented in sandbox).
1086
+
1087
+ Returns:
1088
+ JSON:API response with updated lead record.
1089
+ """
1090
+ return await _http_update_record("Leads", lead_id, {"assigned_user_id": user_id})
1091
+
1092
+
1093
+ @mcp.tool()
1094
+ async def convert_lead(
1095
+ lead_id: str,
1096
+ create_account: Optional[bool] = True,
1097
+ create_contact: Optional[bool] = True,
1098
+ create_opportunity: Optional[bool] = False,
1099
+ opportunity_name: Optional[str] = None,
1100
+ opportunity_amount: Optional[str] = None,
1101
+ ) -> Any:
1102
+ """Convert a lead into account, contact, and/or opportunity records.
1103
+
1104
+ Args:
1105
+ lead_id: Lead record GUID to convert.
1106
+ create_account: Whether to create an Account from the lead (default True).
1107
+ create_contact: Whether to create a Contact from the lead (default True).
1108
+ create_opportunity: Whether to create an Opportunity (default False).
1109
+ opportunity_name: Name for the new opportunity (required if create_opportunity=True).
1110
+ opportunity_amount: Amount for the new opportunity.
1111
+
1112
+ Returns:
1113
+ Dictionary with lead_id and created record IDs (account_id, contact_id, opportunity_id).
1114
+ """
1115
+ lead_resp = await _http_get_entry("Leads", lead_id)
1116
+ data = lead_resp.get("data") if isinstance(lead_resp, dict) else None
1117
+ if not data or not isinstance(data, dict):
1118
+ return {"error": "Lead not found"}
1119
+ attrs = data.get("attributes", {})
1120
+ results: Dict[str, Any] = {"lead_id": lead_id}
1121
+
1122
+ if create_account:
1123
+ account_attrs = _sanitize_attributes({
1124
+ "name": attrs.get("account_name") or f"{attrs.get('first_name','')} {attrs.get('last_name','')} - Account",
1125
+ "phone_office": attrs.get("phone_work"),
1126
+ "website": attrs.get("website"),
1127
+ "description": attrs.get("description"),
1128
+ })
1129
+ acc = await _http_create_record("Accounts", account_attrs)
1130
+ if isinstance(acc, dict) and acc.get("data") and acc["data"].get("id"):
1131
+ results["account_id"] = acc["data"]["id"]
1132
+
1133
+ if create_contact:
1134
+ contact_attrs = _sanitize_attributes({
1135
+ "first_name": attrs.get("first_name"),
1136
+ "last_name": attrs.get("last_name"),
1137
+ "title": attrs.get("title"),
1138
+ "email1": attrs.get("email1"),
1139
+ "phone_work": attrs.get("phone_work"),
1140
+ "phone_mobile": attrs.get("phone_mobile"),
1141
+ "description": attrs.get("description"),
1142
+ })
1143
+ con = await _http_create_record("Contacts", contact_attrs)
1144
+ if isinstance(con, dict) and con.get("data") and con["data"].get("id"):
1145
+ results["contact_id"] = con["data"]["id"]
1146
+
1147
+ if create_opportunity and opportunity_name:
1148
+ opportunity_attrs = {
1149
+ "name": opportunity_name,
1150
+ "amount": opportunity_amount or "",
1151
+ "sales_stage": "Prospecting",
1152
+ "lead_source": attrs.get("lead_source"),
1153
+ "description": attrs.get("description"),
1154
+ }
1155
+ opp = await _http_create_record("Opportunities", opportunity_attrs)
1156
+ if isinstance(opp, dict) and opp.get("data") and opp["data"].get("id"):
1157
+ results["opportunity_id"] = opp["data"]["id"]
1158
+
1159
+ await _http_update_record("Leads", lead_id, {"status": "Converted"})
1160
+ return results
1161
+
1162
+
1163
+ @mcp.tool()
1164
+ async def get_lead_status_options() -> Any:
1165
+ """Get available lead status option values.
1166
+
1167
+ Returns:
1168
+ List of valid status codes (e.g., "New", "Assigned", "In Process", "Converted").
1169
+ """
1170
+ return await _get_field_options("Leads", "status")
1171
+
1172
+
1173
+ @mcp.tool()
1174
+ async def get_lead_source_options() -> Any:
1175
+ """Get available lead source option values.
1176
+
1177
+ Returns:
1178
+ List of valid lead source codes (e.g., "Web", "Cold Call", "Referral").
1179
+ """
1180
+ return await _get_field_options("Leads", "lead_source")
1181
+
1182
+
1183
+ @mcp.tool()
1184
+ async def duplicate_contact(id: str) -> Any:
1185
+ """Create a new Contact by duplicating an existing contact's attributes.
1186
+
1187
+ Args:
1188
+ id: Contact GUID to duplicate.
1189
+
1190
+ Returns:
1191
+ JSON:API response with the newly created contact record.
1192
+ """
1193
+ orig = await _http_get_entry("Contacts", id)
1194
+ data = orig.get("data") if isinstance(orig, dict) else None
1195
+ if not data or not isinstance(data, dict):
1196
+ return {"error": f"Contact {id} not found"}
1197
+ attrs = _sanitize_attributes(data.get("attributes", {}))
1198
+ return await _http_create_record("Contacts", attrs)
1199
+
1200
+
1201
+ @mcp.tool()
1202
+ async def duplicate_lead(lead_id: str) -> Any:
1203
+ """Create a new Lead by duplicating an existing lead's attributes.
1204
+
1205
+ Args:
1206
+ lead_id: Lead GUID to duplicate.
1207
+
1208
+ Returns:
1209
+ JSON:API response with the newly created lead record.
1210
+ """
1211
+ orig = await _http_get_entry("Leads", lead_id)
1212
+ data = orig.get("data") if isinstance(orig, dict) else None
1213
+ if not data or not isinstance(data, dict):
1214
+ return {"error": f"Lead {lead_id} not found"}
1215
+ attrs = _sanitize_attributes(data.get("attributes", {}))
1216
+ return await _http_create_record("Leads", attrs)
1217
+
1218
+
1219
+ @mcp.tool()
1220
+ async def merge_leads(primaryLeadId: str, duplicateLeadIds: List[str]) -> Any:
1221
+ """Merge multiple duplicate leads into a primary lead.
1222
+
1223
+ Combines attributes from duplicate leads into the primary lead, filling in
1224
+ any missing fields. The duplicate leads are deleted after merging.
1225
+
1226
+ Args:
1227
+ primaryLeadId: GUID of the lead to keep and merge into.
1228
+ duplicateLeadIds: List of GUIDs for leads to merge and delete.
1229
+
1230
+ Returns:
1231
+ Dictionary with 'mergedLead' (updated lead data) and 'mergedDuplicateIds'.
1232
+ """
1233
+ primary = await _http_get_entry("Leads", primaryLeadId)
1234
+ pdata = primary.get("data") if isinstance(primary, dict) else None
1235
+ if not pdata:
1236
+ return {"error": f"Primary lead {primaryLeadId} not found"}
1237
+ primary_attrs = dict(pdata.get("attributes", {}))
1238
+ for dup_id in duplicateLeadIds:
1239
+ dup = await _http_get_entry("Leads", dup_id)
1240
+ ddata = dup.get("data") if isinstance(dup, dict) else None
1241
+ if not ddata:
1242
+ continue
1243
+ for k, v in (ddata.get("attributes", {}) or {}).items():
1244
+ if not primary_attrs.get(k) or str(primary_attrs.get(k)).strip() == "":
1245
+ primary_attrs[k] = v
1246
+ san = _sanitize_attributes(primary_attrs)
1247
+ updated = await _http_update_record("Leads", primaryLeadId, san)
1248
+ for dup_id in duplicateLeadIds:
1249
+ try:
1250
+ await _http_delete_entry("Leads", dup_id)
1251
+ except Exception:
1252
+ pass
1253
+ return {"mergedLead": updated, "mergedDuplicateIds": duplicateLeadIds}
1254
+
1255
+
1256
+ @mcp.tool()
1257
+ async def delete_lead(lead_id: str) -> Any:
1258
+ """Delete a lead record.
1259
+
1260
+ Args:
1261
+ lead_id: Lead GUID to delete.
1262
+
1263
+ Returns:
1264
+ JSON:API response confirming deletion.
1265
+ """
1266
+ return await _http_delete_entry("Leads", lead_id)
1267
+
1268
+
1269
+ # ============ Contact-specific tools ============
1270
+
1271
+ @mcp.tool()
1272
+ async def create_contact(
1273
+ first_name: str,
1274
+ last_name: str,
1275
+ title: Optional[str] = None,
1276
+ account_id: Optional[str] = None,
1277
+ department: Optional[str] = None,
1278
+ phone_work: Optional[str] = None,
1279
+ phone_mobile: Optional[str] = None,
1280
+ email1: Optional[str] = None,
1281
+ description: Optional[str] = None,
1282
+ assigned_user_id: Optional[str] = None,
1283
+ ) -> Any:
1284
+ """Create a new Contact with common fields and optional account linkage.
1285
+
1286
+ Args:
1287
+ first_name: Contact's first name (required).
1288
+ last_name: Contact's last name (required).
1289
+ title: Job title.
1290
+ account_id: Company/organization account GUID to link this contact to (stored in the `Accounts` module).
1291
+ department: Department name.
1292
+ phone_work: Work phone number.
1293
+ phone_mobile: Mobile phone number.
1294
+ email1: Primary email address.
1295
+ description: Additional notes or description.
1296
+ assigned_user_id: User GUID to assign the contact to.
1297
+
1298
+ Returns:
1299
+ JSON:API response with the newly created contact record.
1300
+ """
1301
+ attributes: Dict[str, Any] = {
1302
+ "first_name": first_name,
1303
+ "last_name": last_name,
1304
+ }
1305
+ if title:
1306
+ attributes["title"] = title
1307
+ if department:
1308
+ attributes["department"] = department
1309
+ if phone_work:
1310
+ attributes["phone_work"] = phone_work
1311
+ if phone_mobile:
1312
+ attributes["phone_mobile"] = phone_mobile
1313
+ if description:
1314
+ attributes["description"] = description
1315
+ if account_id:
1316
+ attributes["account_id"] = account_id
1317
+ if email1:
1318
+ attributes["email1"] = email1
1319
+ if assigned_user_id:
1320
+ attributes["assigned_user_id"] = assigned_user_id
1321
+
1322
+ return await _http_create_record("Contacts", attributes)
1323
+
1324
+
1325
+ @mcp.tool()
1326
+ async def search_contacts(
1327
+ search_term: Optional[str] = None,
1328
+ first_name: Optional[str] = None,
1329
+ last_name: Optional[str] = None,
1330
+ assigned_user_id: Optional[str] = None,
1331
+ created_after: Optional[str] = None,
1332
+ created_before: Optional[str] = None,
1333
+ page: Optional[int] = 1,
1334
+ page_size: Optional[int] = 20,
1335
+ search_operator: Optional[str] = "or",
1336
+ ) -> Any:
1337
+ """Search contacts by person name, assignee, and date range.
1338
+
1339
+ Args:
1340
+ search_term: Person name search in "FirstName LastName" format (e.g., "John Doe").
1341
+ - Multi-word: First word matches first_name, last word matches last_name (AND).
1342
+ - Single word: Searches across first_name and last_name (OR).
1343
+ For more precise control, use first_name and last_name parameters instead.
1344
+ first_name: Filter by first name (partial match with LIKE).
1345
+ last_name: Filter by last name (partial match with LIKE).
1346
+ assigned_user_id: Filter by assigned user GUID.
1347
+ created_after: ISO date string (YYYY-MM-DD) for minimum creation date.
1348
+ created_before: ISO date string (YYYY-MM-DD) for maximum creation date.
1349
+ page: Index of the target page to retrieve (1-based, default 1).
1350
+ page_size: Number of records per page to segment the contact list (default 20).
1351
+ search_operator: Logical operator for combining filters ("or" or "and", default "or").
1352
+ Note: When search_term has multiple words, "and" is used automatically for name matching.
1353
+
1354
+ Returns:
1355
+ JSON:API response with matching contact records.
1356
+ """
1357
+ filters: Dict[str, Any] = {}
1358
+
1359
+ # Handle search_term - intelligently parse multi-word searches
1360
+ if search_term:
1361
+ words = search_term.strip().split()
1362
+ if len(words) >= 2:
1363
+ # Multi-word search: assume "first_name last_name" format
1364
+ # Use AND to match both parts
1365
+ filters["operator"] = "and"
1366
+ filters["first_name"] = {"like": f"%{words[0]}%"}
1367
+ filters["last_name"] = {"like": f"%{words[-1]}%"}
1368
+ else:
1369
+ # Single word: search across all name fields with OR
1370
+ filters["operator"] = "or"
1371
+ like = f"%{search_term}%"
1372
+ filters["first_name"] = {"like": like}
1373
+ filters["last_name"] = {"like": like}
1374
+ else:
1375
+ # No search_term - use explicit first_name/last_name if provided
1376
+ filters["operator"] = search_operator if search_operator in ("or", "and") else "and"
1377
+ if first_name:
1378
+ filters["first_name"] = {"like": f"%{first_name}%"}
1379
+ if last_name:
1380
+ filters["last_name"] = {"like": f"%{last_name}%"}
1381
+
1382
+ # Add other filters
1383
+ if assigned_user_id:
1384
+ filters["assigned_user_id"] = {"eq": assigned_user_id}
1385
+ if created_after or created_before:
1386
+ filters["date_entered"] = {}
1387
+ if created_after:
1388
+ filters["date_entered"]["gt"] = created_after
1389
+ if created_before:
1390
+ filters["date_entered"]["lt"] = created_before
1391
+
1392
+ return await _http_list_records("Contacts", filters, "-date_entered", page or 1, page_size or 20, ["first_name", "last_name", "phone_work", "phone_mobile", "email1"]) # type: ignore[arg-type]
1393
+
1394
+
1395
+ @mcp.tool()
1396
+ async def update_contact(
1397
+ contact_id: str,
1398
+ first_name: Optional[str] = None,
1399
+ last_name: Optional[str] = None,
1400
+ title: Optional[str] = None,
1401
+ phone_work: Optional[str] = None,
1402
+ phone_mobile: Optional[str] = None,
1403
+ description: Optional[str] = None,
1404
+ assigned_user_id: Optional[str] = None,
1405
+ account_id: Optional[str] = None,
1406
+ ) -> Any:
1407
+ """Update a Contact's fields (partial update).
1408
+
1409
+ Note: To update email addresses, use update_contact_email instead.
1410
+ SuiteCRM stores emails in a separate table that requires special handling.
1411
+
1412
+ Args:
1413
+ contact_id: Contact GUID to update.
1414
+ first_name: New first name.
1415
+ last_name: New last name.
1416
+ title: New job title.
1417
+ phone_work: New work phone number.
1418
+ phone_mobile: New mobile phone number.
1419
+ description: New description or notes.
1420
+ assigned_user_id: New assigned user GUID.
1421
+ account_id: Account GUID to link this contact to.
1422
+
1423
+ Returns:
1424
+ JSON:API response with updated contact record.
1425
+ """
1426
+ attributes: Dict[str, Any] = {}
1427
+ if first_name is not None:
1428
+ attributes["first_name"] = first_name
1429
+ if last_name is not None:
1430
+ attributes["last_name"] = last_name
1431
+ if title is not None:
1432
+ attributes["title"] = title
1433
+ if phone_work is not None:
1434
+ attributes["phone_work"] = phone_work
1435
+ if phone_mobile is not None:
1436
+ attributes["phone_mobile"] = phone_mobile
1437
+ if description is not None:
1438
+ attributes["description"] = description
1439
+ if assigned_user_id is not None:
1440
+ attributes["assigned_user_id"] = assigned_user_id
1441
+ if account_id is not None:
1442
+ attributes["account_id"] = account_id
1443
+ return await _http_update_record("Contacts", contact_id, attributes)
1444
+
1445
+
1446
+ @mcp.tool()
1447
+ async def assign_contact(contact_id: str, user_id: str, send_notification: Optional[bool] = True) -> Any: # noqa: ARG002
1448
+ """Assign a contact to a user.
1449
+
1450
+ Args:
1451
+ contact_id: Contact GUID to reassign.
1452
+ user_id: User GUID to assign the contact to.
1453
+ send_notification: Whether to send notification (not implemented in sandbox).
1454
+
1455
+ Returns:
1456
+ JSON:API response with updated contact record.
1457
+ """
1458
+ return await _http_update_record("Contacts", contact_id, {"assigned_user_id": user_id})
1459
+
1460
+
1461
+ @mcp.tool()
1462
+ async def link_contact_to_account(contact_id: str, account_id: str) -> Any:
1463
+ """Link a contact to an account via relationships.
1464
+
1465
+ Args:
1466
+ contact_id: Contact GUID to link.
1467
+ account_id: Account GUID to link the contact to.
1468
+
1469
+ Returns:
1470
+ JSON:API response confirming the relationship update.
1471
+ """
1472
+ relationships = {
1473
+ "contacts": {"data": {"type": "Contacts", "id": contact_id}},
1474
+ }
1475
+ return await _http_update_record("Accounts", account_id, {}, relationships)
1476
+
1477
+
1478
+ @mcp.tool()
1479
+ async def get_contact_by_id(id: str) -> Any:
1480
+ """Get a Contact by ID.
1481
+
1482
+ Args:
1483
+ id: Contact GUID.
1484
+
1485
+ Returns:
1486
+ JSON:API response with contact record data.
1487
+ """
1488
+ return await _http_get_entry("Contacts", id)
1489
+
1490
+
1491
+ @mcp.tool()
1492
+ async def delete_contact(id: str) -> Any:
1493
+ """Delete a Contact record.
1494
+
1495
+ Args:
1496
+ id: Contact GUID to delete.
1497
+
1498
+ Returns:
1499
+ JSON:API response confirming deletion.
1500
+ """
1501
+ return await _http_delete_entry("Contacts", id)
1502
+
1503
+
1504
+ @mcp.tool()
1505
+ async def update_contact_email(contact_id: str, old_email: str, new_email: str) -> Any:
1506
+ """Update a contact's email address.
1507
+
1508
+ SuiteCRM stores emails in a separate table (EmailAddresses), so this tool
1509
+ finds the email record linked to the contact and updates it via the
1510
+ authenticated JSON:API.
1511
+
1512
+ Args:
1513
+ contact_id: Contact GUID whose email should be updated.
1514
+ old_email: The current email address to replace.
1515
+ new_email: The new email address to set.
1516
+
1517
+ Returns:
1518
+ Dictionary with success status and updated email information.
1519
+ """
1520
+ try:
1521
+ # Step 1: Get the contact's email relationships to find the EmailAddress record
1522
+ email_rels = await _http_get_relationships("Contacts", contact_id, "email_addresses", page=1, page_size=50)
1523
+
1524
+ if isinstance(email_rels, dict) and "error" in email_rels:
1525
+ return {
1526
+ "success": False,
1527
+ "contact_id": contact_id,
1528
+ "error": f"Failed to get email relationships: {email_rels.get('error')}",
1529
+ }
1530
+
1531
+ # Step 2: Find the EmailAddress record matching old_email
1532
+ email_id = None
1533
+ data = email_rels.get("data", [])
1534
+ for email_rec in data:
1535
+ attrs = email_rec.get("attributes", {})
1536
+ if attrs.get("email_address", "").lower() == old_email.lower():
1537
+ email_id = email_rec.get("id")
1538
+ break
1539
+
1540
+ if not email_id:
1541
+ return {
1542
+ "success": False,
1543
+ "contact_id": contact_id,
1544
+ "old_email": old_email,
1545
+ "error": f"Email address '{old_email}' not found for contact {contact_id}",
1546
+ }
1547
+
1548
+ # Step 3: Update the EmailAddress record via standard authenticated API
1549
+ result = await _http_update_record(
1550
+ "EmailAddresses",
1551
+ email_id,
1552
+ {"email_address": new_email, "email_address_caps": new_email.upper()}
1553
+ )
1554
+
1555
+ if isinstance(result, dict) and "error" in result:
1556
+ return {
1557
+ "success": False,
1558
+ "contact_id": contact_id,
1559
+ "old_email": old_email,
1560
+ "new_email": new_email,
1561
+ "error": result.get("error"),
1562
+ }
1563
+
1564
+ return {
1565
+ "success": True,
1566
+ "contact_id": contact_id,
1567
+ "old_email": old_email,
1568
+ "new_email": new_email,
1569
+ "message": f"Email updated from {old_email} to {new_email}",
1570
+ "data": result,
1571
+ }
1572
+
1573
+ except Exception as e:
1574
+ return {
1575
+ "success": False,
1576
+ "contact_id": contact_id,
1577
+ "error": str(e),
1578
+ }
1579
+
1580
+
1581
+ @mcp.tool()
1582
+ async def view_contact_history(id: str) -> Any:
1583
+ """Get a contact's related activities and history.
1584
+
1585
+ Aggregates calls, meetings, emails, cases, notes, and tasks linked to the contact.
1586
+
1587
+ Args:
1588
+ id: Contact GUID.
1589
+
1590
+ Returns:
1591
+ Dictionary with keys 'calls', 'meetings', 'emails', 'cases', 'notes', 'tasks',
1592
+ each containing a list of related records.
1593
+ """
1594
+ fields = ["calls", "meetings", "emails", "cases", "notes", "tasks"]
1595
+ history: Dict[str, Any] = {}
1596
+ for field in fields:
1597
+ try:
1598
+ resp = await _http_get_relationships("Contacts", id, field, 1, 50)
1599
+ if isinstance(resp, dict):
1600
+ history[field] = resp.get("data")
1601
+ except Exception:
1602
+ pass
1603
+ return history
1604
+
1605
+
1606
+ # ============ Case-specific tools ============
1607
+
1608
+
1609
+ @mcp.tool()
1610
+ async def create_case(
1611
+ name: str,
1612
+ status: str = "Open_New",
1613
+ priority: str = "P2",
1614
+ case_type: str = "User",
1615
+ description: Optional[str] = None,
1616
+ account_id: Optional[str] = None,
1617
+ contact_id: Optional[str] = None,
1618
+ assigned_user_id: Optional[str] = None,
1619
+ ) -> Any:
1620
+ """Create a new Case (support ticket) record.
1621
+
1622
+ Args:
1623
+ name: Case subject/title (required). E.g., 'Product not working as expected'.
1624
+ status: Case status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
1625
+ 'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'. Defaults to 'Open_New'.
1626
+ priority: Case priority code. Use 'P1' (High), 'P2' (Medium), or 'P3' (Low). Defaults to 'P2'.
1627
+ case_type: Case type. E.g., 'User', 'Product', 'Administration'.
1628
+ description: Case description/details.
1629
+ account_id: Account GUID to link this case to.
1630
+ contact_id: Contact GUID to link this case to.
1631
+ assigned_user_id: User GUID to assign the case to.
1632
+
1633
+ Returns:
1634
+ JSON:API response with the newly created case record.
1635
+ """
1636
+ attributes: Dict[str, Any] = {
1637
+ "name": name,
1638
+ "status": status,
1639
+ "priority": priority,
1640
+ "type": case_type,
1641
+ }
1642
+ if description:
1643
+ attributes["description"] = description
1644
+ if account_id:
1645
+ attributes["account_id"] = account_id
1646
+ if contact_id:
1647
+ attributes["contact_id"] = contact_id
1648
+ if assigned_user_id:
1649
+ attributes["assigned_user_id"] = assigned_user_id
1650
+
1651
+ return await _http_create_record("Cases", attributes)
1652
+
1653
+
1654
+ @mcp.tool()
1655
+ async def search_cases(
1656
+ search_term: Optional[str] = None,
1657
+ status: Optional[str] = None,
1658
+ priority: Optional[str] = None,
1659
+ case_type: Optional[str] = None,
1660
+ account_id: Optional[str] = None,
1661
+ assigned_user_id: Optional[str] = None,
1662
+ page: Optional[int] = 1,
1663
+ page_size: Optional[int] = 20,
1664
+ search_operator: Optional[str] = "or",
1665
+ ) -> Any:
1666
+ """Search cases by text, status, priority, type, account, and assignee.
1667
+
1668
+ Args:
1669
+ search_term: Free-text search across case name/subject AND description.
1670
+ status: Case status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
1671
+ 'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'.
1672
+ priority: Case priority code ('P1', 'P2', 'P3').
1673
+ case_type: Case type (e.g., 'User', 'Product', 'Administration').
1674
+ account_id: Filter by linked account GUID.
1675
+ assigned_user_id: Filter by assigned user GUID.
1676
+ page: Index of the target page to retrieve (1-based, default 1).
1677
+ page_size: Number of records per page to segment the case list (default 20).
1678
+ search_operator: Logical operator for combining filters ('or' or 'and', default 'or').
1679
+
1680
+ Returns:
1681
+ JSON:API response with matching case records.
1682
+ """
1683
+ filters: Dict[str, Any] = {}
1684
+ filters["operator"] = search_operator if search_operator in ("or", "and") else "or"
1685
+ if search_term:
1686
+ # Search both name and description fields
1687
+ filters["name"] = {"like": f"%{search_term}%"}
1688
+ filters["description"] = {"like": f"%{search_term}%"}
1689
+ if status:
1690
+ filters["status"] = {"eq": status}
1691
+ if priority:
1692
+ filters["priority"] = {"eq": priority}
1693
+ if case_type:
1694
+ filters["type"] = {"eq": case_type}
1695
+ if account_id:
1696
+ filters["account_id"] = {"eq": account_id}
1697
+ if assigned_user_id:
1698
+ filters["assigned_user_id"] = {"eq": assigned_user_id}
1699
+
1700
+ return await _http_list_records(
1701
+ "Cases",
1702
+ filters if filters else None,
1703
+ "-date_entered",
1704
+ page or 1,
1705
+ page_size or 20,
1706
+ ["name", "status", "priority", "type", "description", "date_entered"],
1707
+ )
1708
+
1709
+
1710
+ @mcp.tool()
1711
+ async def update_case(
1712
+ case_id: str,
1713
+ name: Optional[str] = None,
1714
+ status: Optional[str] = None,
1715
+ priority: Optional[str] = None,
1716
+ case_type: Optional[str] = None,
1717
+ description: Optional[str] = None,
1718
+ resolution: Optional[str] = None,
1719
+ assigned_user_id: Optional[str] = None,
1720
+ ) -> Any:
1721
+ """Update a Case record (partial update).
1722
+
1723
+ Args:
1724
+ case_id: Case GUID to update.
1725
+ name: New case subject/title.
1726
+ status: New status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
1727
+ 'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'.
1728
+ priority: New priority code ('P1', 'P2', 'P3').
1729
+ case_type: New case type (e.g., 'User', 'Product', 'Administration').
1730
+ description: New description.
1731
+ resolution: Resolution text (typically set when closing the case).
1732
+ assigned_user_id: New assigned user GUID.
1733
+
1734
+ Returns:
1735
+ JSON:API response with updated case record.
1736
+ """
1737
+ attributes: Dict[str, Any] = {}
1738
+ if name is not None:
1739
+ attributes["name"] = name
1740
+ if status is not None:
1741
+ attributes["status"] = status
1742
+ if priority is not None:
1743
+ attributes["priority"] = priority
1744
+ if case_type is not None:
1745
+ attributes["type"] = case_type
1746
+ if description is not None:
1747
+ attributes["description"] = description
1748
+ if resolution is not None:
1749
+ attributes["resolution"] = resolution
1750
+ if assigned_user_id is not None:
1751
+ attributes["assigned_user_id"] = assigned_user_id
1752
+
1753
+ return await _http_update_record("Cases", case_id, attributes)
1754
+
1755
+
1756
+ @mcp.tool()
1757
+ async def get_case_by_id(case_id: str) -> Any:
1758
+ """Get a Case by ID.
1759
+
1760
+ Args:
1761
+ case_id: Case GUID.
1762
+
1763
+ Returns:
1764
+ JSON:API response with case record data.
1765
+ """
1766
+ return await _http_get_entry("Cases", case_id)
1767
+
1768
+
1769
+ @mcp.tool()
1770
+ async def delete_case(case_id: str) -> Any:
1771
+ """Delete a Case record.
1772
+
1773
+ Args:
1774
+ case_id: Case GUID to delete.
1775
+
1776
+ Returns:
1777
+ JSON:API response confirming deletion.
1778
+ """
1779
+ return await _http_delete_entry("Cases", case_id)
1780
+
1781
+
1782
+ # ============ Account-specific tools ============
1783
+
1784
+
1785
+ @mcp.tool()
1786
+ async def search_accounts(
1787
+ search_term: Optional[str] = None,
1788
+ industry: Optional[str] = None,
1789
+ page: Optional[int] = None,
1790
+ page_size: Optional[int] = None,
1791
+ ) -> Any:
1792
+ """Search for Account records.
1793
+
1794
+ Args:
1795
+ search_term: Text to search for in account names.
1796
+ industry: Filter by industry (e.g., 'Technology', 'Healthcare', 'Finance').
1797
+ page: Page number for pagination (1-based).
1798
+ page_size: Number of records per page (default 20).
1799
+
1800
+ Returns:
1801
+ JSON:API response with matching accounts including id, name, industry, billing address fields.
1802
+ """
1803
+ filters: Dict[str, Any] = {}
1804
+ if search_term:
1805
+ filters["name"] = {"like": f"%{search_term}%"}
1806
+ if industry:
1807
+ filters["industry"] = {"eq": industry}
1808
+
1809
+ return await _http_list_records(
1810
+ "Accounts",
1811
+ filters,
1812
+ "-date_entered",
1813
+ page or 1,
1814
+ page_size or 20,
1815
+ ["name", "industry", "phone_office", "billing_address_city", "billing_address_country"],
1816
+ )
1817
+
1818
+
1819
+ @mcp.tool()
1820
+ async def get_account_by_id(account_id: str) -> Any:
1821
+ """Get an Account by ID.
1822
+
1823
+ Args:
1824
+ account_id: Account GUID.
1825
+
1826
+ Returns:
1827
+ JSON:API response with account record data.
1828
+ """
1829
+ return await _http_get_entry("Accounts", account_id)
1830
+
1831
+
1832
+ # ============ Opportunity-specific tools ============
1833
+
1834
+
1835
+ @mcp.tool()
1836
+ async def create_opportunity(
1837
+ name: str,
1838
+ amount: Optional[float] = None,
1839
+ sales_stage: str = "Prospecting",
1840
+ date_closed: Optional[str] = None,
1841
+ account_id: Optional[str] = None,
1842
+ description: Optional[str] = None,
1843
+ probability: Optional[int] = None,
1844
+ lead_source: Optional[str] = None,
1845
+ assigned_user_id: Optional[str] = None,
1846
+ ) -> Any:
1847
+ """Create a new Opportunity record.
1848
+
1849
+ Args:
1850
+ name: Opportunity name/title (required). E.g., 'Website Redesign Project - Acme Corp'.
1851
+ amount: Expected deal amount in dollars.
1852
+ sales_stage: Sales stage. Common values: 'Prospecting', 'Qualification',
1853
+ 'Needs Analysis', 'Value Proposition', 'Negotiation/Review',
1854
+ 'Closed Won', 'Closed Lost'. Defaults to 'Prospecting'.
1855
+ date_closed: Expected close date in YYYY-MM-DD format.
1856
+ account_id: Account GUID to link this opportunity to.
1857
+ description: Opportunity description/details.
1858
+ probability: Win probability percentage (0-100).
1859
+ lead_source: How the opportunity originated (e.g., 'Web Site', 'Referral', 'Trade Show').
1860
+ assigned_user_id: User GUID to assign the opportunity to.
1861
+
1862
+ Returns:
1863
+ JSON:API response with the newly created opportunity record.
1864
+ """
1865
+ attributes: Dict[str, Any] = {
1866
+ "name": name,
1867
+ "sales_stage": sales_stage,
1868
+ }
1869
+ if amount is not None:
1870
+ attributes["amount"] = amount
1871
+ if date_closed:
1872
+ attributes["date_closed"] = date_closed
1873
+ if account_id:
1874
+ attributes["account_id"] = account_id
1875
+ if description:
1876
+ attributes["description"] = description
1877
+ if probability is not None:
1878
+ attributes["probability"] = probability
1879
+ if lead_source:
1880
+ attributes["lead_source"] = lead_source
1881
+ if assigned_user_id:
1882
+ attributes["assigned_user_id"] = assigned_user_id
1883
+
1884
+ return await _http_create_record("Opportunities", attributes)
1885
+
1886
+
1887
+ @mcp.tool()
1888
+ async def search_opportunities(
1889
+ search_term: Optional[str] = None,
1890
+ sales_stage: Optional[str] = None,
1891
+ account_id: Optional[str] = None,
1892
+ page: Optional[int] = None,
1893
+ page_size: Optional[int] = None,
1894
+ ) -> Any:
1895
+ """Search for Opportunity records.
1896
+
1897
+ Args:
1898
+ search_term: Text to search for in opportunity names.
1899
+ sales_stage: Filter by sales stage (e.g., 'Prospecting', 'Closed Won').
1900
+ account_id: Filter by account GUID.
1901
+ page: Page number for pagination (1-based).
1902
+ page_size: Number of records per page (default 20).
1903
+
1904
+ Returns:
1905
+ JSON:API response with matching opportunities including id, name, amount, sales_stage, date_closed.
1906
+ """
1907
+ # When account_id is provided, use the relationship endpoint because
1908
+ # SuiteCRM does not expose account_id as a filterable column on
1909
+ # Opportunities (it's stored in a relationship table).
1910
+ if account_id:
1911
+ result = await _http_get_relationships(
1912
+ "Accounts", account_id, "opportunities",
1913
+ page=page or 1, page_size=page_size or 20,
1914
+ )
1915
+ # Apply additional filters client-side if provided
1916
+ if (search_term or sales_stage) and isinstance(result, dict) and "data" in result:
1917
+ filtered = []
1918
+ for rec in result["data"]:
1919
+ attrs = rec.get("attributes", {})
1920
+ if search_term and search_term.lower() not in attrs.get("name", "").lower():
1921
+ continue
1922
+ if sales_stage and attrs.get("sales_stage") != sales_stage:
1923
+ continue
1924
+ filtered.append(rec)
1925
+ result["data"] = filtered
1926
+ result.setdefault("meta", {})["records-on-this-page"] = len(filtered)
1927
+ return result
1928
+
1929
+ filters: Dict[str, Any] = {}
1930
+ if search_term:
1931
+ filters["name"] = {"like": f"%{search_term}%"}
1932
+ if sales_stage:
1933
+ filters["sales_stage"] = {"eq": sales_stage}
1934
+
1935
+ return await _http_list_records(
1936
+ "Opportunities",
1937
+ filters,
1938
+ "-date_entered",
1939
+ page or 1,
1940
+ page_size or 20,
1941
+ ["name", "amount", "sales_stage", "date_closed", "probability"],
1942
+ )
1943
+
1944
+
1945
+ @mcp.tool()
1946
+ async def get_opportunity_by_id(opportunity_id: str) -> Any:
1947
+ """Get an Opportunity by ID.
1948
+
1949
+ Args:
1950
+ opportunity_id: Opportunity GUID.
1951
+
1952
+ Returns:
1953
+ JSON:API response with opportunity record data.
1954
+ """
1955
+ return await _http_get_entry("Opportunities", opportunity_id)
1956
+
1957
+
1958
+ @mcp.tool()
1959
+ async def update_opportunity(
1960
+ opportunity_id: str,
1961
+ name: Optional[str] = None,
1962
+ amount: Optional[float] = None,
1963
+ sales_stage: Optional[str] = None,
1964
+ date_closed: Optional[str] = None,
1965
+ description: Optional[str] = None,
1966
+ probability: Optional[int] = None,
1967
+ assigned_user_id: Optional[str] = None,
1968
+ ) -> Any:
1969
+ """Update an Opportunity's fields (partial update).
1970
+
1971
+ Args:
1972
+ opportunity_id: Opportunity GUID to update.
1973
+ name: New opportunity name.
1974
+ amount: New deal amount.
1975
+ sales_stage: New sales stage.
1976
+ date_closed: New expected close date (YYYY-MM-DD).
1977
+ description: New description.
1978
+ probability: New win probability (0-100).
1979
+ assigned_user_id: New assigned user GUID.
1980
+
1981
+ Returns:
1982
+ JSON:API response with updated opportunity record.
1983
+ """
1984
+ attributes: Dict[str, Any] = {}
1985
+ if name is not None:
1986
+ attributes["name"] = name
1987
+ if amount is not None:
1988
+ attributes["amount"] = amount
1989
+ if sales_stage is not None:
1990
+ attributes["sales_stage"] = sales_stage
1991
+ if date_closed is not None:
1992
+ attributes["date_closed"] = date_closed
1993
+ if description is not None:
1994
+ attributes["description"] = description
1995
+ if probability is not None:
1996
+ attributes["probability"] = probability
1997
+ if assigned_user_id is not None:
1998
+ attributes["assigned_user_id"] = assigned_user_id
1999
+
2000
+ return await _http_update_record("Opportunities", opportunity_id, attributes)
2001
+
2002
+
2003
+ if __name__ == "__main__":
2004
+ main()
2005
+
2006
+