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,1554 @@
1
+ """
2
+ Atlassian MCP Server (Jira + Confluence Sandbox)
3
+
4
+ This MCP server provides tools for interacting with a local Jira-like
5
+ issue tracking and Confluence-like page management system.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import json
11
+ from typing import Optional, Dict, Any, List
12
+
13
+ import httpx
14
+ from fastmcp import FastMCP
15
+ from pathlib import Path
16
+
17
+ try:
18
+ import yaml
19
+ except Exception:
20
+ yaml = None
21
+
22
+
23
+ # Configuration
24
+ ATLASSIAN_API = os.getenv("ATLASSIAN_API_URL", "http://localhost:8040")
25
+ DEFAULT_USER_ACCESS_TOKEN = os.getenv("USER_ACCESS_TOKEN", "")
26
+ # Default cloudId for sandbox environment
27
+ DEFAULT_CLOUD_ID = os.getenv("ATLASSIAN_CLOUD_ID", "sandbox-cloud-001")
28
+
29
+ mcp = FastMCP("Atlassian MCP Server (Jira/Confluence Sandbox)")
30
+
31
+
32
+ def _port_from_registry(default_port: int) -> int:
33
+ """Resolve port from registry.yaml as a static fallback."""
34
+ try:
35
+ if yaml is None:
36
+ return default_port
37
+ registry_path = Path(__file__).resolve().parent.parent / "registry.yaml"
38
+ if not registry_path.exists():
39
+ return default_port
40
+ data = yaml.safe_load(registry_path.read_text()) or {}
41
+ service_name = Path(__file__).resolve().parent.name
42
+ for srv in (data.get("servers") or []):
43
+ if isinstance(srv, dict) and srv.get("name") == service_name:
44
+ env = srv.get("env") or {}
45
+ port_str = str(env.get("PORT") or "").strip().strip('"')
46
+ return int(port_str) if port_str else default_port
47
+ except Exception:
48
+ return default_port
49
+ return default_port
50
+
51
+
52
+ def _resolve_token(token: Optional[str] = None) -> str:
53
+ """Resolve the access token to use."""
54
+ return (token or DEFAULT_USER_ACCESS_TOKEN or "").strip()
55
+
56
+
57
+ def _headers(token: Optional[str] = None) -> Dict[str, str]:
58
+ """Build request headers with authentication."""
59
+ resolved = _resolve_token(token)
60
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
61
+ if resolved:
62
+ headers["Authorization"] = f"Bearer {resolved}"
63
+ return headers
64
+
65
+
66
+ def _parse_list_param(val: Any) -> Optional[List[str]]:
67
+ """Parse a parameter that should be a list but might be a JSON string."""
68
+ if val is None:
69
+ return None
70
+ if isinstance(val, list):
71
+ return val
72
+ if isinstance(val, str):
73
+ val = val.strip()
74
+ if val.startswith('['):
75
+ try:
76
+ parsed = json.loads(val)
77
+ if isinstance(parsed, list):
78
+ return parsed
79
+ except Exception:
80
+ pass
81
+ if val:
82
+ return [val]
83
+ return None
84
+
85
+
86
+ def _normalize_priority(value: Any) -> str:
87
+ """Map Jira-like priority inputs to backend-supported enum values."""
88
+ priority = str(value or "medium").strip().lower()
89
+ mapping = {
90
+ "critical": "highest",
91
+ "highest": "highest",
92
+ "high": "high",
93
+ "medium": "medium",
94
+ "low": "low",
95
+ "lowest": "lowest",
96
+ "p1": "highest",
97
+ "p2": "high",
98
+ "p3": "medium",
99
+ "p4": "low",
100
+ "p5": "lowest",
101
+ }
102
+ return mapping.get(priority, "medium")
103
+
104
+
105
+ def _normalize_issue_reference(value: Any) -> str:
106
+ """Normalize issue references for fuzzy matching."""
107
+ return str(value or "").strip().lower()
108
+
109
+
110
+ def _issue_matches_reference(item: Dict[str, Any], reference: str) -> bool:
111
+ """Check whether a search result matches an issue key/reference loosely."""
112
+ ref = _normalize_issue_reference(reference)
113
+ if not ref:
114
+ return False
115
+ key = _normalize_issue_reference(item.get("key"))
116
+ if key == ref:
117
+ return True
118
+ haystacks = [
119
+ item.get("title"),
120
+ item.get("summary"),
121
+ item.get("description"),
122
+ ]
123
+ return any(ref in _normalize_issue_reference(value) for value in haystacks)
124
+
125
+
126
+ async def _resolve_user_id(client: httpx.AsyncClient, user_ref: Optional[str]) -> Optional[str]:
127
+ """Resolve user IDs from UUIDs, emails, or display-name-like references."""
128
+ raw = str(user_ref or "").strip()
129
+ if not raw:
130
+ return None
131
+ if _looks_like_uuid(raw):
132
+ return raw
133
+
134
+ for endpoint, params in (
135
+ (f"{ATLASSIAN_API}/api/users/search", {"query": raw}),
136
+ (f"{ATLASSIAN_API}/api/users", {"search": raw}),
137
+ ):
138
+ r = await client.get(endpoint, params=params, headers=_headers())
139
+ if r.status_code != 200:
140
+ continue
141
+ payload = r.json()
142
+ items = payload if isinstance(payload, list) else payload.get("items", [])
143
+ if not isinstance(items, list):
144
+ continue
145
+ raw_lower = raw.lower()
146
+ for item in items:
147
+ if not isinstance(item, dict):
148
+ continue
149
+ item_id = item.get("id")
150
+ if not item_id:
151
+ continue
152
+ emails = [
153
+ item.get("email"),
154
+ item.get("emailAddress"),
155
+ ]
156
+ names = [
157
+ item.get("displayName"),
158
+ item.get("name"),
159
+ ]
160
+ if any(str(v or "").strip().lower() == raw_lower for v in emails + names):
161
+ return str(item_id)
162
+ if items and isinstance(items[0], dict) and items[0].get("id"):
163
+ return str(items[0]["id"])
164
+ return raw
165
+
166
+
167
+ def _looks_like_uuid(value: Optional[str]) -> bool:
168
+ """Return True when a value looks like a UUID string."""
169
+ if not value:
170
+ return False
171
+ text = str(value).strip()
172
+ return len(text) == 36 and text.count("-") == 4
173
+
174
+
175
+ async def _resolve_issue_id(client: httpx.AsyncClient, issue_id_or_key: str) -> str:
176
+ """Resolve Jira issue keys like ACME-1 to backend UUIDs.
177
+
178
+ The sandbox write/detail endpoints expect UUIDs, but agents frequently pass
179
+ issue keys. Scan paginated listings conservatively before falling back.
180
+ """
181
+ key = str(issue_id_or_key or "").strip()
182
+ if not key or _looks_like_uuid(key):
183
+ return key
184
+
185
+ direct_search = await client.get(
186
+ f"{ATLASSIAN_API}/api/issues/search",
187
+ params={"q": key, "limit": 25, "offset": 0},
188
+ headers=_headers(),
189
+ )
190
+ if direct_search.status_code == 200:
191
+ items = direct_search.json().get("items", [])
192
+ for item in items:
193
+ if isinstance(item, dict) and _issue_matches_reference(item, key) and item.get("id"):
194
+ return str(item["id"])
195
+
196
+ page_size = 200
197
+ for offset in range(0, 1000, page_size):
198
+ search_r = await client.get(
199
+ f"{ATLASSIAN_API}/api/issues/search",
200
+ params={"limit": page_size, "offset": offset},
201
+ headers=_headers(),
202
+ )
203
+ if search_r.status_code != 200:
204
+ break
205
+ items = search_r.json().get("items", [])
206
+ for item in items:
207
+ if isinstance(item, dict) and _issue_matches_reference(item, key) and item.get("id"):
208
+ return str(item["id"])
209
+ if len(items) < page_size:
210
+ break
211
+
212
+ project_key = key.split("-", 1)[0].strip().upper()
213
+ if project_key:
214
+ project_r = await client.get(
215
+ f"{ATLASSIAN_API}/api/projects",
216
+ params={"search": project_key, "limit": 50, "offset": 0},
217
+ headers=_headers(),
218
+ )
219
+ if project_r.status_code == 200:
220
+ for project in project_r.json().get("items", []):
221
+ if (project.get("key") or "").upper() != project_key:
222
+ continue
223
+ project_id = project.get("id")
224
+ if not project_id:
225
+ continue
226
+ for offset in range(0, 1000, page_size):
227
+ list_r = await client.get(
228
+ f"{ATLASSIAN_API}/api/issues/projects/{project_id}",
229
+ params={"limit": page_size, "offset": offset},
230
+ headers=_headers(),
231
+ )
232
+ if list_r.status_code != 200:
233
+ break
234
+ items = list_r.json().get("items", [])
235
+ for item in items:
236
+ if item.get("key") == key and item.get("id"):
237
+ return str(item["id"])
238
+ if len(items) < page_size:
239
+ break
240
+
241
+ return key
242
+
243
+
244
+ # ============================================================================
245
+ # General Atlassian Tools
246
+ # ============================================================================
247
+
248
+ @mcp.tool()
249
+ async def atlassianUserInfo() -> Any:
250
+ """Get current user info from Atlassian.
251
+
252
+ Returns:
253
+ Current user profile information including id, display name, email
254
+ """
255
+ async with httpx.AsyncClient() as client:
256
+ r = await client.get(
257
+ f"{ATLASSIAN_API}/api/auth/me",
258
+ headers=_headers()
259
+ )
260
+ r.raise_for_status()
261
+ return r.json()
262
+
263
+
264
+ @mcp.tool()
265
+ async def getAccessibleAtlassianResources() -> Any:
266
+ """Get cloudId to construct API calls to Atlassian REST APIs.
267
+
268
+ Returns:
269
+ List of accessible resources including cloud IDs for Jira and Confluence
270
+ """
271
+ async with httpx.AsyncClient() as client:
272
+ r = await client.get(
273
+ f"{ATLASSIAN_API}/api/projects",
274
+ headers=_headers()
275
+ )
276
+ r.raise_for_status()
277
+ data = r.json()
278
+ return [{
279
+ "id": DEFAULT_CLOUD_ID,
280
+ "name": "Sandbox Atlassian",
281
+ "url": ATLASSIAN_API,
282
+ "scopes": ["read:jira-work", "write:jira-work", "read:confluence-content.all", "write:confluence-content"],
283
+ "avatarUrl": None
284
+ }]
285
+
286
+
287
+ @mcp.tool()
288
+ async def search(query: str) -> Any:
289
+ """Search across Jira and Confluence using Rovo Search (use by default for searches).
290
+
291
+ Args:
292
+ query: Search query text (required)
293
+
294
+ Returns:
295
+ Unified search results from Jira and Confluence
296
+ """
297
+ results = {"query": query, "results": []}
298
+
299
+ async with httpx.AsyncClient() as client:
300
+ # Search Jira issues
301
+ r = await client.get(
302
+ f"{ATLASSIAN_API}/api/issues/search",
303
+ params={"q": query, "limit": 25},
304
+ headers=_headers()
305
+ )
306
+ if r.status_code == 200:
307
+ data = r.json()
308
+ for item in data.get("items", []):
309
+ results["results"].append({
310
+ "type": "jira_issue",
311
+ "id": item.get("id"),
312
+ "title": item.get("title"),
313
+ "key": item.get("key"),
314
+ "url": f"{ATLASSIAN_API}/issues/{item.get('id')}"
315
+ })
316
+
317
+ # Search Confluence pages (projects as spaces in sandbox)
318
+ r = await client.get(
319
+ f"{ATLASSIAN_API}/api/projects",
320
+ params={"search": query, "limit": 25},
321
+ headers=_headers()
322
+ )
323
+ if r.status_code == 200:
324
+ data = r.json()
325
+ for item in data.get("items", []):
326
+ results["results"].append({
327
+ "type": "confluence_space",
328
+ "id": item.get("id"),
329
+ "title": item.get("name"),
330
+ "key": item.get("key"),
331
+ "url": f"{ATLASSIAN_API}/projects/{item.get('id')}"
332
+ })
333
+
334
+ return results
335
+
336
+
337
+ @mcp.tool()
338
+ async def fetch(id: str) -> Any:
339
+ """Get details by ARI (Atlassian Resource Identifier).
340
+
341
+ Args:
342
+ id: Atlassian Resource Identifier (required)
343
+
344
+ Returns:
345
+ Resource details (Jira issue or Confluence page)
346
+ """
347
+ # Parse ARI to extract resource type and ID
348
+ parts = id.split("/")
349
+ resource_id = parts[-1] if parts else id
350
+
351
+ # Determine if it's a Jira issue or Confluence page
352
+ if "issue" in id.lower() or "jira" in id.lower():
353
+ return await getJiraIssue(cloudId=DEFAULT_CLOUD_ID, issueIdOrKey=resource_id)
354
+ elif "page" in id.lower() or "confluence" in id.lower():
355
+ return await getConfluencePage(cloudId=DEFAULT_CLOUD_ID, pageId=resource_id)
356
+ else:
357
+ # Try as issue first
358
+ try:
359
+ return await getJiraIssue(cloudId=DEFAULT_CLOUD_ID, issueIdOrKey=resource_id)
360
+ except Exception:
361
+ return await getConfluencePage(cloudId=DEFAULT_CLOUD_ID, pageId=resource_id)
362
+
363
+
364
+ # ============================================================================
365
+ # Jira - Projects
366
+ # ============================================================================
367
+
368
+ @mcp.tool()
369
+ async def getVisibleJiraProjects(
370
+ cloudId: str,
371
+ searchString: Optional[str] = None,
372
+ action: Optional[str] = None,
373
+ expandIssueTypes: Optional[bool] = None,
374
+ startAt: Optional[int] = None,
375
+ maxResults: Optional[int] = None
376
+ ) -> Any:
377
+ """Get Jira projects visible to the user.
378
+
379
+ Args:
380
+ cloudId: Cloud instance ID (required)
381
+ searchString: Optional search query to filter projects by name
382
+ action: Optional action to filter by (e.g., "view", "edit")
383
+ expandIssueTypes: Whether to expand issue types
384
+ startAt: Index of the first result to return (default: 0)
385
+ maxResults: Maximum number of results to return (default: 25)
386
+
387
+ Returns:
388
+ List of accessible projects with their details
389
+ """
390
+ params = {
391
+ "limit": maxResults or 25,
392
+ "offset": startAt or 0
393
+ }
394
+ if searchString:
395
+ params["search"] = searchString
396
+
397
+ async with httpx.AsyncClient() as client:
398
+ r = await client.get(
399
+ f"{ATLASSIAN_API}/api/projects",
400
+ params=params,
401
+ headers=_headers()
402
+ )
403
+ r.raise_for_status()
404
+ return r.json()
405
+
406
+
407
+ @mcp.tool()
408
+ async def getJiraProjectIssueTypesMetadata(
409
+ cloudId: str,
410
+ projectIdOrKey: str,
411
+ startAt: Optional[int] = None,
412
+ maxResults: Optional[int] = None
413
+ ) -> Any:
414
+ """Get all issue type metadata for a project.
415
+
416
+ Args:
417
+ cloudId: Cloud instance ID (required)
418
+ projectIdOrKey: The UUID or key of the project (required)
419
+ startAt: Index of the first result to return
420
+ maxResults: Maximum number of results to return
421
+
422
+ Returns:
423
+ Issue type metadata including available types (story, task, bug, epic)
424
+ """
425
+ return {
426
+ "projectId": projectIdOrKey,
427
+ "issueTypes": [
428
+ {"id": "story", "name": "Story", "description": "User story", "subtask": False},
429
+ {"id": "task", "name": "Task", "description": "A task to be done", "subtask": False},
430
+ {"id": "bug", "name": "Bug", "description": "A defect or issue", "subtask": False},
431
+ {"id": "epic", "name": "Epic", "description": "A large body of work", "subtask": False}
432
+ ],
433
+ "startAt": startAt or 0,
434
+ "maxResults": maxResults or 50,
435
+ "total": 4
436
+ }
437
+
438
+
439
+ @mcp.tool()
440
+ async def getJiraIssueTypeMetaWithFields(
441
+ cloudId: str,
442
+ projectIdOrKey: str,
443
+ issueTypeId: str,
444
+ startAt: Optional[int] = None,
445
+ maxResults: Optional[int] = None
446
+ ) -> Any:
447
+ """Get metadata for a specific issue type in a project.
448
+
449
+ Args:
450
+ cloudId: Cloud instance ID (required)
451
+ projectIdOrKey: The UUID or key of the project (required)
452
+ issueTypeId: The issue type ID (required)
453
+ startAt: Index of the first result to return
454
+ maxResults: Maximum number of results to return
455
+
456
+ Returns:
457
+ Field metadata for creating issues of the specified type
458
+ """
459
+ return {
460
+ "projectId": projectIdOrKey,
461
+ "issueTypeId": issueTypeId,
462
+ "fields": {
463
+ "summary": {"required": True, "schema": {"type": "string"}, "name": "Summary"},
464
+ "description": {"required": False, "schema": {"type": "string"}, "name": "Description"},
465
+ "priority": {"required": True, "schema": {"type": "priority"}, "name": "Priority",
466
+ "allowedValues": [
467
+ {"id": "highest", "name": "Highest"},
468
+ {"id": "high", "name": "High"},
469
+ {"id": "medium", "name": "Medium"},
470
+ {"id": "low", "name": "Low"},
471
+ {"id": "lowest", "name": "Lowest"}
472
+ ]},
473
+ "assignee": {"required": False, "schema": {"type": "user"}, "name": "Assignee"},
474
+ "labels": {"required": False, "schema": {"type": "array", "items": "string"}, "name": "Labels"},
475
+ "duedate": {"required": False, "schema": {"type": "date"}, "name": "Due Date"}
476
+ },
477
+ "startAt": startAt or 0,
478
+ "maxResults": maxResults or 50
479
+ }
480
+
481
+
482
+ @mcp.tool()
483
+ async def lookupJiraAccountId(cloudId: str, searchString: str) -> Any:
484
+ """Look up user account IDs by display name or email.
485
+
486
+ Args:
487
+ cloudId: Cloud instance ID (required)
488
+ searchString: Search query - display name or email (required)
489
+
490
+ Returns:
491
+ List of matching user accounts
492
+ """
493
+ async with httpx.AsyncClient() as client:
494
+ r = await client.get(
495
+ f"{ATLASSIAN_API}/api/users/search",
496
+ params={"query": searchString},
497
+ headers=_headers()
498
+ )
499
+ if r.status_code == 404:
500
+ # Try alternate endpoint
501
+ r = await client.get(
502
+ f"{ATLASSIAN_API}/api/users",
503
+ params={"search": searchString},
504
+ headers=_headers()
505
+ )
506
+ if r.status_code == 200:
507
+ return r.json()
508
+ return []
509
+
510
+
511
+ # ============================================================================
512
+ # Jira - Issues
513
+ # ============================================================================
514
+
515
+ @mcp.tool()
516
+ async def getJiraIssue(
517
+ cloudId: str,
518
+ issueIdOrKey: str,
519
+ expand: Optional[str] = None,
520
+ fields: Optional[List[str]] = None,
521
+ fieldsByKeys: Optional[bool] = None,
522
+ properties: Optional[List[str]] = None,
523
+ updateHistory: Optional[bool] = None,
524
+ failFast: Optional[bool] = None
525
+ ) -> Any:
526
+ """Get details of a Jira issue by ID or key.
527
+
528
+ Args:
529
+ cloudId: Cloud instance ID (required)
530
+ issueIdOrKey: The UUID or key (e.g., PROJ-123) of the issue (required)
531
+ expand: Comma-separated list of fields to expand
532
+ fields: List of fields to return
533
+ fieldsByKeys: Whether fields are specified by keys
534
+ properties: List of properties to return
535
+ updateHistory: Whether to update view history
536
+ failFast: Whether to fail fast on errors
537
+
538
+ Returns:
539
+ Full issue details including status, assignee, comments, etc.
540
+ """
541
+ async with httpx.AsyncClient() as client:
542
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
543
+ if not issue_id:
544
+ return {
545
+ "id": str(issueIdOrKey),
546
+ "key": str(issueIdOrKey),
547
+ "notFound": True,
548
+ "message": f"Issue '{issueIdOrKey}' not found",
549
+ }
550
+ r = await client.get(
551
+ f"{ATLASSIAN_API}/api/issues/{issue_id}",
552
+ headers=_headers()
553
+ )
554
+ if r.status_code == 404:
555
+ return {
556
+ "id": str(issueIdOrKey),
557
+ "key": str(issueIdOrKey),
558
+ "notFound": True,
559
+ "message": f"Issue '{issueIdOrKey}' not found",
560
+ }
561
+ r.raise_for_status()
562
+ return r.json()
563
+
564
+
565
+ @mcp.tool()
566
+ async def createJiraIssue(
567
+ cloudId: str,
568
+ projectKey: str,
569
+ issueTypeName: str,
570
+ summary: str,
571
+ description: Optional[str] = None,
572
+ assignee_account_id: Optional[str] = None,
573
+ parent: Optional[str] = None,
574
+ additional_fields: Optional[Dict[str, Any]] = None
575
+ ) -> Any:
576
+ """Creates a new Jira issue in a project.
577
+
578
+ Args:
579
+ cloudId: Cloud instance ID (required)
580
+ projectKey: Project key, e.g., "PROJ" (required)
581
+ issueTypeName: Issue type name: story, task, bug, epic (required)
582
+ summary: Issue title/summary (required)
583
+ description: Detailed description
584
+ assignee_account_id: Account ID of the assignee
585
+ parent: Parent issue key (for subtasks)
586
+ additional_fields: Additional fields as object (priority, labels, dueDate, etc.)
587
+
588
+ Returns:
589
+ Created issue details
590
+ """
591
+ async with httpx.AsyncClient() as client:
592
+ r = await client.get(
593
+ f"{ATLASSIAN_API}/api/projects",
594
+ params={"search": projectKey},
595
+ headers=_headers()
596
+ )
597
+ r.raise_for_status()
598
+ projects = r.json().get("items", [])
599
+ project = next((p for p in projects if p.get("key") == projectKey), None)
600
+ if not project:
601
+ raise ValueError(f"Project {projectKey} not found")
602
+ project_id = project["id"]
603
+
604
+ # Map issue type to valid types (story, task, bug, epic)
605
+ issue_type = issueTypeName.lower()
606
+ valid_types = {"story", "task", "bug", "epic"}
607
+ type_mapping = {"incident": "task", "feature": "story", "improvement": "story", "subtask": "task"}
608
+ if issue_type not in valid_types:
609
+ issue_type = type_mapping.get(issue_type, "task")
610
+
611
+ body = {
612
+ "projectId": project_id,
613
+ "title": summary,
614
+ "type": issue_type,
615
+ "priority": "medium"
616
+ }
617
+
618
+ if description:
619
+ body["description"] = description
620
+ if assignee_account_id:
621
+ resolved_assignee_id = await _resolve_user_id(client, assignee_account_id)
622
+ if resolved_assignee_id:
623
+ body["assigneeId"] = resolved_assignee_id
624
+ if parent:
625
+ body["parentIssueId"] = await _resolve_issue_id(client, parent)
626
+
627
+ if additional_fields:
628
+ if "priority" in additional_fields:
629
+ p = additional_fields["priority"]
630
+ if isinstance(p, dict):
631
+ p = p.get("name", p.get("id", "medium"))
632
+ body["priority"] = _normalize_priority(p)
633
+ if "labels" in additional_fields:
634
+ body["labels"] = _parse_list_param(additional_fields["labels"])
635
+ if "dueDate" in additional_fields:
636
+ body["dueDate"] = additional_fields["dueDate"]
637
+ if "storyPoints" in additional_fields:
638
+ body["storyPoints"] = additional_fields["storyPoints"]
639
+
640
+ r = await client.post(
641
+ f"{ATLASSIAN_API}/api/issues",
642
+ json=body,
643
+ headers=_headers()
644
+ )
645
+ r.raise_for_status()
646
+ return r.json()
647
+
648
+
649
+ @mcp.tool()
650
+ async def editJiraIssue(
651
+ cloudId: str,
652
+ issueIdOrKey: str,
653
+ fields: Dict[str, Any]
654
+ ) -> Any:
655
+ """Update details of an existing Jira issue.
656
+
657
+ Args:
658
+ cloudId: Cloud instance ID (required)
659
+ issueIdOrKey: The UUID or key of the issue (required)
660
+ fields: Object containing fields to update (required)
661
+ Supported: summary, description, priority, assignee, labels, dueDate
662
+
663
+ Returns:
664
+ Updated issue details
665
+ """
666
+ body = {}
667
+
668
+ # Map field names to backend format
669
+ field_mapping = {
670
+ "summary": "title",
671
+ "description": "description",
672
+ "priority": "priority",
673
+ "assignee": "assigneeId",
674
+ "labels": "labels",
675
+ "dueDate": "dueDate",
676
+ "duedate": "dueDate"
677
+ }
678
+
679
+ for key, value in fields.items():
680
+ if key in field_mapping:
681
+ backend_key = field_mapping[key]
682
+ if key == "assignee" and isinstance(value, dict):
683
+ body[backend_key] = value.get("accountId") or value.get("id")
684
+ elif key == "labels":
685
+ body[backend_key] = _parse_list_param(value)
686
+ elif key == "priority" and isinstance(value, dict):
687
+ body[backend_key] = _normalize_priority(value.get("name") or value.get("id") or "medium")
688
+ elif key == "priority":
689
+ body[backend_key] = _normalize_priority(value)
690
+ else:
691
+ body[backend_key] = value
692
+
693
+ async with httpx.AsyncClient() as client:
694
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
695
+ r = await client.patch(
696
+ f"{ATLASSIAN_API}/api/issues/{issue_id}",
697
+ json=body,
698
+ headers=_headers()
699
+ )
700
+ r.raise_for_status()
701
+ return r.json()
702
+
703
+
704
+ @mcp.tool()
705
+ async def addCommentToJiraIssue(
706
+ cloudId: str,
707
+ issueIdOrKey: str,
708
+ commentBody: str,
709
+ commentVisibility: Optional[Dict[str, Any]] = None
710
+ ) -> Any:
711
+ """Adds a comment to a Jira issue.
712
+
713
+ Args:
714
+ cloudId: Cloud instance ID (required)
715
+ issueIdOrKey: The UUID or key of the issue (required)
716
+ commentBody: The comment text content (required)
717
+ commentVisibility: Visibility settings (optional)
718
+
719
+ Returns:
720
+ Created comment details
721
+ """
722
+ async with httpx.AsyncClient() as client:
723
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
724
+ r = await client.post(
725
+ f"{ATLASSIAN_API}/api/issues/{issue_id}/comments",
726
+ json={"body": commentBody},
727
+ headers=_headers()
728
+ )
729
+ r.raise_for_status()
730
+ return r.json()
731
+
732
+
733
+ @mcp.tool()
734
+ async def addWorklogToJiraIssue(
735
+ cloudId: str,
736
+ issueIdOrKey: str,
737
+ timeSpent: str,
738
+ visibility: Optional[Dict[str, Any]] = None
739
+ ) -> Any:
740
+ """Adds a worklog entry to a Jira issue.
741
+
742
+ Args:
743
+ cloudId: Cloud instance ID (required)
744
+ issueIdOrKey: The UUID or key of the issue (required)
745
+ timeSpent: Time spent string, e.g., "2h 30m" (required)
746
+ visibility: Visibility settings (optional)
747
+
748
+ Returns:
749
+ Created worklog details
750
+ """
751
+ # Parse timeSpent to seconds
752
+ seconds = 0
753
+ time_str = timeSpent.lower()
754
+ if 'h' in time_str:
755
+ parts = time_str.split('h')
756
+ seconds += int(parts[0].strip()) * 3600
757
+ time_str = parts[1] if len(parts) > 1 else ""
758
+ if 'm' in time_str:
759
+ parts = time_str.split('m')
760
+ seconds += int(parts[0].strip()) * 60
761
+
762
+ async with httpx.AsyncClient() as client:
763
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
764
+ r = await client.post(
765
+ f"{ATLASSIAN_API}/api/issues/{issue_id}/worklog",
766
+ json={"timeSpentSeconds": seconds},
767
+ headers=_headers()
768
+ )
769
+ if r.status_code == 404:
770
+ return {"message": "Worklog added (simulated)", "timeSpent": timeSpent, "timeSpentSeconds": seconds}
771
+ r.raise_for_status()
772
+ return r.json()
773
+
774
+
775
+ @mcp.tool()
776
+ async def getTransitionsForJiraIssue(
777
+ cloudId: str,
778
+ issueIdOrKey: str,
779
+ expand: Optional[str] = None,
780
+ transitionId: Optional[str] = None,
781
+ skipRemoteOnlyCondition: Optional[bool] = None,
782
+ includeUnavailableTransitions: Optional[bool] = None,
783
+ sortByOpsBarAndStatus: Optional[bool] = None
784
+ ) -> Any:
785
+ """Get available transitions for a Jira issue.
786
+
787
+ Args:
788
+ cloudId: Cloud instance ID (required)
789
+ issueIdOrKey: The UUID or key of the issue (required)
790
+ expand: Comma-separated list to expand
791
+ transitionId: Filter by specific transition ID
792
+ skipRemoteOnlyCondition: Skip remote-only conditions
793
+ includeUnavailableTransitions: Include unavailable transitions
794
+ sortByOpsBarAndStatus: Sort by ops bar and status
795
+
796
+ Returns:
797
+ List of available status transitions
798
+ """
799
+ async with httpx.AsyncClient() as client:
800
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
801
+ r = await client.get(
802
+ f"{ATLASSIAN_API}/api/issues/{issue_id}/transitions",
803
+ headers=_headers()
804
+ )
805
+ r.raise_for_status()
806
+ data = r.json()
807
+
808
+ # Format as Jira API response
809
+ transitions = data if isinstance(data, list) else data.get("items", [])
810
+ return {
811
+ "expand": expand or "",
812
+ "transitions": transitions
813
+ }
814
+
815
+
816
+ @mcp.tool()
817
+ async def transitionJiraIssue(
818
+ cloudId: str,
819
+ issueIdOrKey: str,
820
+ transition: Dict[str, Any],
821
+ fields: Optional[Dict[str, Any]] = None,
822
+ update: Optional[Dict[str, Any]] = None,
823
+ historyMetadata: Optional[Dict[str, Any]] = None
824
+ ) -> Any:
825
+ """Transition a Jira issue to a new status.
826
+
827
+ Args:
828
+ cloudId: Cloud instance ID (required)
829
+ issueIdOrKey: The UUID or key of the issue (required)
830
+ transition: Transition object with id field (required)
831
+ fields: Fields to update during transition
832
+ update: Update operations during transition
833
+ historyMetadata: Metadata for history
834
+
835
+ Returns:
836
+ Updated issue details after transition
837
+ """
838
+ transition_id = transition.get("id") if isinstance(transition, dict) else transition
839
+
840
+ body = {"transitionId": transition_id}
841
+ if fields and "status" in fields:
842
+ body["toStatusId"] = fields["status"].get("id") if isinstance(fields["status"], dict) else fields["status"]
843
+
844
+ async with httpx.AsyncClient() as client:
845
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
846
+ r = await client.post(
847
+ f"{ATLASSIAN_API}/api/issues/{issue_id}/transitions",
848
+ json=body,
849
+ headers=_headers()
850
+ )
851
+ r.raise_for_status()
852
+ return r.json()
853
+
854
+
855
+ @mcp.tool()
856
+ async def getJiraIssueRemoteIssueLinks(
857
+ cloudId: str,
858
+ issueIdOrKey: str,
859
+ globalId: Optional[str] = None
860
+ ) -> Any:
861
+ """Get remote issue links (e.g., Confluence links) for a Jira issue.
862
+
863
+ Args:
864
+ cloudId: Cloud instance ID (required)
865
+ issueIdOrKey: The UUID or key of the issue (required)
866
+ globalId: Filter by global ID
867
+
868
+ Returns:
869
+ List of remote links associated with the issue
870
+ """
871
+ async with httpx.AsyncClient() as client:
872
+ issue_id = await _resolve_issue_id(client, issueIdOrKey)
873
+ r = await client.get(
874
+ f"{ATLASSIAN_API}/api/issues/{issue_id}/remotelinks",
875
+ headers=_headers()
876
+ )
877
+ if r.status_code == 404:
878
+ return []
879
+ r.raise_for_status()
880
+ return r.json()
881
+
882
+
883
+ @mcp.tool()
884
+ async def searchJiraIssuesUsingJql(
885
+ cloudId: str,
886
+ jql: str,
887
+ fields: Optional[List[str]] = None,
888
+ maxResults: Optional[int] = None,
889
+ nextPageToken: Optional[str] = None
890
+ ) -> Any:
891
+ """Search Jira issues using JQL (only use when JQL is specifically mentioned).
892
+
893
+ Args:
894
+ cloudId: Cloud instance ID (required)
895
+ jql: JQL query string (required)
896
+ fields: List of fields to return
897
+ maxResults: Maximum number of results
898
+ nextPageToken: Token for pagination
899
+
900
+ Returns:
901
+ Search results with matching issues
902
+ """
903
+ import re
904
+
905
+ limit = maxResults or 25
906
+
907
+ async with httpx.AsyncClient() as client:
908
+ # Parse JQL to extract project key and search text
909
+ project_key = None
910
+ issue_key = None
911
+ search_text = None
912
+
913
+ # Match: project = KAN or project = "KAN"
914
+ project_match = re.search(r'project\s*=\s*["\']?(\w+)["\']?', jql, re.IGNORECASE)
915
+ if project_match:
916
+ project_key = project_match.group(1).upper()
917
+
918
+ # Match: key = KAN-1 or issue key pattern like KAN-123
919
+ key_match = re.search(r'(?:key\s*=\s*)?["\']?([A-Z]+-\d+)["\']?', jql, re.IGNORECASE)
920
+ if key_match:
921
+ issue_key = key_match.group(1).upper()
922
+
923
+ # Extract text search terms (after removing project/key clauses)
924
+ remaining = jql
925
+ remaining = re.sub(r'project\s*=\s*["\']?\w+["\']?', '', remaining, flags=re.IGNORECASE)
926
+ remaining = re.sub(r'key\s*=\s*["\']?[\w-]+["\']?', '', remaining, flags=re.IGNORECASE)
927
+ remaining = re.sub(r'\s*(AND|OR)\s*', ' ', remaining, flags=re.IGNORECASE)
928
+ # Extract quoted text or remaining words
929
+ quoted = re.findall(r'["\']([^"\']+)["\']', remaining)
930
+ if quoted:
931
+ search_text = ' '.join(quoted)
932
+ else:
933
+ remaining = remaining.strip()
934
+ if remaining and not remaining.upper().startswith(('ORDER', 'LIMIT')):
935
+ search_text = remaining
936
+
937
+ # If we have an issue key, try to find it directly
938
+ if issue_key:
939
+ all_issues_r = await client.get(
940
+ f"{ATLASSIAN_API}/api/issues/search",
941
+ params={"limit": 100},
942
+ headers=_headers()
943
+ )
944
+ if all_issues_r.status_code == 200:
945
+ all_data = all_issues_r.json()
946
+ matching = [i for i in all_data.get("items", []) if i.get("key") == issue_key]
947
+ if matching:
948
+ return {"items": matching, "total": len(matching), "limit": limit, "offset": 0}
949
+
950
+ # If we have a project key, get project ID and filter
951
+ project_id = None
952
+ if project_key:
953
+ projects_r = await client.get(f"{ATLASSIAN_API}/api/projects", headers=_headers())
954
+ if projects_r.status_code == 200:
955
+ projects_data = projects_r.json()
956
+ # Handle both list and {"items": [...]} formats
957
+ projects_list = projects_data.get("items", projects_data) if isinstance(projects_data, dict) else projects_data
958
+ for p in projects_list:
959
+ if p.get("key", "").upper() == project_key:
960
+ project_id = p.get("id")
961
+ break
962
+
963
+ # Build search params
964
+ params = {"limit": limit}
965
+ if project_id:
966
+ params["projectId"] = project_id
967
+ if search_text:
968
+ params["q"] = search_text
969
+
970
+ # If we have project but no search text, list all project issues
971
+ if project_id and not search_text:
972
+ r = await client.get(f"{ATLASSIAN_API}/api/issues", params=params, headers=_headers())
973
+ else:
974
+ # Default: use search endpoint
975
+ if not params.get("q"):
976
+ params["q"] = jql # Fallback to original JQL as search text
977
+ r = await client.get(f"{ATLASSIAN_API}/api/issues/search", params=params, headers=_headers())
978
+
979
+ r.raise_for_status()
980
+ result = r.json()
981
+
982
+ # If no results and we have a project key, try listing all project issues
983
+ if result.get("total", 0) == 0 and project_id:
984
+ r2 = await client.get(
985
+ f"{ATLASSIAN_API}/api/issues",
986
+ params={"projectId": project_id, "limit": limit},
987
+ headers=_headers()
988
+ )
989
+ if r2.status_code == 200:
990
+ result = r2.json()
991
+
992
+ return result
993
+
994
+
995
+ # ============================================================================
996
+ # Confluence - Spaces & Pages
997
+ # ============================================================================
998
+
999
+ @mcp.tool()
1000
+ async def getConfluenceSpaces(
1001
+ cloudId: str,
1002
+ ids: Optional[str] = None,
1003
+ keys: Optional[str] = None,
1004
+ type: Optional[str] = None,
1005
+ status: Optional[str] = None,
1006
+ labels: Optional[str] = None,
1007
+ favoritedBy: Optional[str] = None,
1008
+ notFavoritedBy: Optional[str] = None,
1009
+ sort: Optional[str] = None,
1010
+ descriptionFormat: Optional[str] = None,
1011
+ includeIcon: Optional[bool] = None,
1012
+ cursor: Optional[str] = None,
1013
+ limit: Optional[int] = None
1014
+ ) -> Any:
1015
+ """Get Confluence spaces.
1016
+
1017
+ Args:
1018
+ cloudId: Cloud instance ID (required)
1019
+ ids: Comma-separated list of space IDs
1020
+ keys: Comma-separated list of space keys
1021
+ type: Space type filter
1022
+ status: Status filter
1023
+ labels: Labels filter
1024
+ favoritedBy: Favorited by user
1025
+ notFavoritedBy: Not favorited by user
1026
+ sort: Sort order
1027
+ descriptionFormat: Format for description
1028
+ includeIcon: Whether to include icon
1029
+ cursor: Pagination cursor
1030
+ limit: Maximum results
1031
+
1032
+ Returns:
1033
+ List of Confluence spaces
1034
+ """
1035
+ params = {"limit": limit or 25, "offset": 0}
1036
+ if keys:
1037
+ params["search"] = keys
1038
+
1039
+ async with httpx.AsyncClient() as client:
1040
+ r = await client.get(
1041
+ f"{ATLASSIAN_API}/api/projects",
1042
+ params=params,
1043
+ headers=_headers()
1044
+ )
1045
+ r.raise_for_status()
1046
+ data = r.json()
1047
+
1048
+ spaces = []
1049
+ for item in data.get("items", []):
1050
+ spaces.append({
1051
+ "id": item.get("id"),
1052
+ "key": item.get("key"),
1053
+ "name": item.get("name"),
1054
+ "description": {"plain": {"value": item.get("description", "")}},
1055
+ "type": "global",
1056
+ "status": "current",
1057
+ "icon": None
1058
+ })
1059
+
1060
+ return {"results": spaces, "_links": {}}
1061
+
1062
+
1063
+ @mcp.tool()
1064
+ async def getConfluencePage(
1065
+ cloudId: str,
1066
+ pageId: str,
1067
+ contentFormat: Optional[str] = None
1068
+ ) -> Any:
1069
+ """Get a specific Confluence page by ID.
1070
+
1071
+ Args:
1072
+ cloudId: Cloud instance ID (required)
1073
+ pageId: The ID of the page (required)
1074
+ contentFormat: Content format (storage, view, atlas_doc_format)
1075
+
1076
+ Returns:
1077
+ Page content and metadata
1078
+ """
1079
+ async with httpx.AsyncClient() as client:
1080
+ r = await client.get(
1081
+ f"{ATLASSIAN_API}/api/issues/{pageId}",
1082
+ headers=_headers()
1083
+ )
1084
+ r.raise_for_status()
1085
+ data = r.json()
1086
+
1087
+ issue = data.get("issue", data)
1088
+ return {
1089
+ "id": issue.get("id"),
1090
+ "type": "page",
1091
+ "status": "current",
1092
+ "title": issue.get("title"),
1093
+ "spaceId": issue.get("project", {}).get("id"),
1094
+ "body": {
1095
+ "storage": {"value": issue.get("description", ""), "representation": "storage"},
1096
+ "view": {"value": issue.get("description", ""), "representation": "view"}
1097
+ },
1098
+ "version": {"number": 1, "createdAt": issue.get("createdAt")},
1099
+ "createdAt": issue.get("createdAt"),
1100
+ "authorId": issue.get("reporter", {}).get("id"),
1101
+ "_links": {"webui": f"/issues/{issue.get('id')}"}
1102
+ }
1103
+
1104
+
1105
+ @mcp.tool()
1106
+ async def createConfluencePage(
1107
+ cloudId: str,
1108
+ spaceId: str,
1109
+ body: str,
1110
+ title: Optional[str] = None,
1111
+ parentId: Optional[str] = None,
1112
+ contentFormat: Optional[str] = None,
1113
+ subtype: Optional[str] = None,
1114
+ isPrivate: Optional[bool] = None
1115
+ ) -> Any:
1116
+ """Create a new page in Confluence.
1117
+
1118
+ Args:
1119
+ cloudId: Cloud instance ID (required)
1120
+ spaceId: The ID of the space (required)
1121
+ body: Page content (required)
1122
+ title: Page title
1123
+ parentId: Parent page ID
1124
+ contentFormat: Content format
1125
+ subtype: Page subtype (page or live_doc)
1126
+ isPrivate: Whether page is private
1127
+
1128
+ Returns:
1129
+ Created page details
1130
+ """
1131
+ issue_body = {
1132
+ "projectId": spaceId,
1133
+ "title": title or "Untitled Page",
1134
+ "description": body,
1135
+ "type": "task"
1136
+ }
1137
+
1138
+ if parentId:
1139
+ issue_body["parentIssueId"] = parentId
1140
+
1141
+ async with httpx.AsyncClient() as client:
1142
+ r = await client.post(
1143
+ f"{ATLASSIAN_API}/api/issues",
1144
+ json=issue_body,
1145
+ headers=_headers()
1146
+ )
1147
+ r.raise_for_status()
1148
+ data = r.json()
1149
+
1150
+ return {
1151
+ "id": data.get("id"),
1152
+ "type": subtype or "page",
1153
+ "status": "current",
1154
+ "title": data.get("title"),
1155
+ "spaceId": spaceId,
1156
+ "body": {"storage": {"value": body, "representation": "storage"}},
1157
+ "version": {"number": 1},
1158
+ "createdAt": data.get("createdAt")
1159
+ }
1160
+
1161
+
1162
+ @mcp.tool()
1163
+ async def updateConfluencePage(
1164
+ cloudId: str,
1165
+ pageId: str,
1166
+ body: str,
1167
+ title: Optional[str] = None,
1168
+ spaceId: Optional[str] = None,
1169
+ parentId: Optional[str] = None,
1170
+ status: Optional[str] = None,
1171
+ contentFormat: Optional[str] = None,
1172
+ versionMessage: Optional[str] = None
1173
+ ) -> Any:
1174
+ """Update an existing Confluence page.
1175
+
1176
+ Args:
1177
+ cloudId: Cloud instance ID (required)
1178
+ pageId: The ID of the page (required)
1179
+ body: Updated page content (required)
1180
+ title: New title
1181
+ spaceId: New space ID
1182
+ parentId: New parent page ID
1183
+ status: Page status
1184
+ contentFormat: Content format
1185
+ versionMessage: Version message
1186
+
1187
+ Returns:
1188
+ Updated page details
1189
+ """
1190
+ update_body = {"description": body}
1191
+ if title:
1192
+ update_body["title"] = title
1193
+
1194
+ async with httpx.AsyncClient() as client:
1195
+ r = await client.patch(
1196
+ f"{ATLASSIAN_API}/api/issues/{pageId}",
1197
+ json=update_body,
1198
+ headers=_headers()
1199
+ )
1200
+ r.raise_for_status()
1201
+ data = r.json()
1202
+
1203
+ return {
1204
+ "id": data.get("id"),
1205
+ "type": "page",
1206
+ "status": "current",
1207
+ "title": data.get("title"),
1208
+ "body": {"storage": {"value": body, "representation": "storage"}},
1209
+ "version": {"number": 2, "message": versionMessage},
1210
+ "updatedAt": data.get("updatedAt")
1211
+ }
1212
+
1213
+
1214
+ @mcp.tool()
1215
+ async def getPagesInConfluenceSpace(
1216
+ cloudId: str,
1217
+ spaceId: str,
1218
+ title: Optional[str] = None,
1219
+ status: Optional[str] = None,
1220
+ subtype: Optional[str] = None,
1221
+ depth: Optional[str] = None,
1222
+ sort: Optional[str] = None,
1223
+ cursor: Optional[str] = None,
1224
+ limit: Optional[int] = None
1225
+ ) -> Any:
1226
+ """Get all pages within a Confluence space.
1227
+
1228
+ Args:
1229
+ cloudId: Cloud instance ID (required)
1230
+ spaceId: The ID of the space (required)
1231
+ title: Filter by title
1232
+ status: Filter by status
1233
+ subtype: Filter by subtype
1234
+ depth: Depth of results
1235
+ sort: Sort order
1236
+ cursor: Pagination cursor
1237
+ limit: Maximum results
1238
+
1239
+ Returns:
1240
+ List of pages in the space
1241
+ """
1242
+ params = {"limit": limit or 25}
1243
+ if title:
1244
+ params["search"] = title
1245
+
1246
+ async with httpx.AsyncClient() as client:
1247
+ r = await client.get(
1248
+ f"{ATLASSIAN_API}/api/issues/projects/{spaceId}",
1249
+ params=params,
1250
+ headers=_headers()
1251
+ )
1252
+ r.raise_for_status()
1253
+ data = r.json()
1254
+
1255
+ pages = []
1256
+ for item in data.get("items", []):
1257
+ pages.append({
1258
+ "id": item.get("id"),
1259
+ "type": "page",
1260
+ "status": "current",
1261
+ "title": item.get("title"),
1262
+ "spaceId": spaceId,
1263
+ "createdAt": item.get("createdAt")
1264
+ })
1265
+
1266
+ return {"results": pages, "_links": {}}
1267
+
1268
+
1269
+ @mcp.tool()
1270
+ async def getConfluencePageDescendants(
1271
+ cloudId: str,
1272
+ pageId: str,
1273
+ depth: Optional[int] = None,
1274
+ cursor: Optional[str] = None,
1275
+ limit: Optional[int] = None
1276
+ ) -> Any:
1277
+ """Get all child pages of a specific page.
1278
+
1279
+ Args:
1280
+ cloudId: Cloud instance ID (required)
1281
+ pageId: The ID of the parent page (required)
1282
+ depth: Depth of descendants to retrieve
1283
+ cursor: Pagination cursor
1284
+ limit: Maximum results
1285
+
1286
+ Returns:
1287
+ List of child pages
1288
+ """
1289
+ async with httpx.AsyncClient() as client:
1290
+ r = await client.get(
1291
+ f"{ATLASSIAN_API}/api/issues/{pageId}",
1292
+ headers=_headers()
1293
+ )
1294
+ r.raise_for_status()
1295
+ issue = r.json()
1296
+
1297
+ issue_data = issue.get("issue", issue)
1298
+ subtasks = issue_data.get("subtasks", [])
1299
+
1300
+ return {
1301
+ "results": [
1302
+ {
1303
+ "id": st.get("id"),
1304
+ "type": "page",
1305
+ "status": "current",
1306
+ "title": st.get("title"),
1307
+ "parentId": pageId
1308
+ }
1309
+ for st in subtasks
1310
+ ],
1311
+ "_links": {}
1312
+ }
1313
+
1314
+
1315
+ @mcp.tool()
1316
+ async def createConfluenceFooterComment(
1317
+ cloudId: str,
1318
+ body: str,
1319
+ pageId: Optional[str] = None,
1320
+ customContentId: Optional[str] = None,
1321
+ attachmentId: Optional[str] = None,
1322
+ parentCommentId: Optional[str] = None
1323
+ ) -> Any:
1324
+ """Create a footer comment on a Confluence page.
1325
+
1326
+ Args:
1327
+ cloudId: Cloud instance ID (required)
1328
+ body: Comment content (required)
1329
+ pageId: Page ID to comment on
1330
+ customContentId: Custom content ID
1331
+ attachmentId: Attachment ID
1332
+ parentCommentId: Parent comment ID for replies
1333
+
1334
+ Returns:
1335
+ Created comment details
1336
+ """
1337
+ target_id = pageId or customContentId or attachmentId
1338
+ if not target_id:
1339
+ raise ValueError("One of pageId, customContentId, or attachmentId is required")
1340
+
1341
+ async with httpx.AsyncClient() as client:
1342
+ r = await client.post(
1343
+ f"{ATLASSIAN_API}/api/issues/{target_id}/comments",
1344
+ json={"body": body},
1345
+ headers=_headers()
1346
+ )
1347
+ r.raise_for_status()
1348
+ data = r.json()
1349
+
1350
+ return {
1351
+ "id": data.get("id"),
1352
+ "type": "footer",
1353
+ "body": {"storage": {"value": body, "representation": "storage"}},
1354
+ "createdAt": data.get("createdAt")
1355
+ }
1356
+
1357
+
1358
+ @mcp.tool()
1359
+ async def createConfluenceInlineComment(
1360
+ cloudId: str,
1361
+ body: str,
1362
+ pageId: Optional[str] = None,
1363
+ parentCommentId: Optional[str] = None,
1364
+ inlineCommentProperties: Optional[Dict[str, Any]] = None
1365
+ ) -> Any:
1366
+ """Create an inline comment on a Confluence page.
1367
+
1368
+ Args:
1369
+ cloudId: Cloud instance ID (required)
1370
+ body: Comment content (required)
1371
+ pageId: Page ID to comment on
1372
+ parentCommentId: Parent comment ID for replies
1373
+ inlineCommentProperties: Properties for inline positioning
1374
+
1375
+ Returns:
1376
+ Created inline comment details
1377
+ """
1378
+ if not pageId:
1379
+ raise ValueError("pageId is required")
1380
+
1381
+ comment_body = {"body": body}
1382
+ if inlineCommentProperties:
1383
+ comment_body["textSelection"] = inlineCommentProperties.get("textSelection", "")
1384
+
1385
+ async with httpx.AsyncClient() as client:
1386
+ r = await client.post(
1387
+ f"{ATLASSIAN_API}/api/issues/{pageId}/comments",
1388
+ json=comment_body,
1389
+ headers=_headers()
1390
+ )
1391
+ r.raise_for_status()
1392
+ data = r.json()
1393
+
1394
+ return {
1395
+ "id": data.get("id"),
1396
+ "type": "inline",
1397
+ "body": {"storage": {"value": body, "representation": "storage"}},
1398
+ "inlineProperties": inlineCommentProperties,
1399
+ "createdAt": data.get("createdAt")
1400
+ }
1401
+
1402
+
1403
+ @mcp.tool()
1404
+ async def getConfluencePageFooterComments(
1405
+ cloudId: str,
1406
+ pageId: str,
1407
+ status: Optional[str] = None,
1408
+ sort: Optional[str] = None,
1409
+ cursor: Optional[str] = None,
1410
+ limit: Optional[int] = None
1411
+ ) -> Any:
1412
+ """Get footer comments for a Confluence page.
1413
+
1414
+ Args:
1415
+ cloudId: Cloud instance ID (required)
1416
+ pageId: The ID of the page (required)
1417
+ status: Filter by status
1418
+ sort: Sort order
1419
+ cursor: Pagination cursor
1420
+ limit: Maximum results
1421
+
1422
+ Returns:
1423
+ List of footer comments
1424
+ """
1425
+ async with httpx.AsyncClient() as client:
1426
+ r = await client.get(
1427
+ f"{ATLASSIAN_API}/api/issues/{pageId}/comments",
1428
+ params={"limit": limit or 25},
1429
+ headers=_headers()
1430
+ )
1431
+ r.raise_for_status()
1432
+ data = r.json()
1433
+
1434
+ comments_list = data if isinstance(data, list) else data.get("items", [])
1435
+ comments = []
1436
+ for item in comments_list:
1437
+ comments.append({
1438
+ "id": item.get("id"),
1439
+ "type": "footer",
1440
+ "body": {"storage": {"value": item.get("body", ""), "representation": "storage"}},
1441
+ "authorId": item.get("author", {}).get("id"),
1442
+ "createdAt": item.get("createdAt")
1443
+ })
1444
+
1445
+ return {"results": comments, "_links": {}}
1446
+
1447
+
1448
+ @mcp.tool()
1449
+ async def getConfluencePageInlineComments(
1450
+ cloudId: str,
1451
+ pageId: str,
1452
+ status: Optional[str] = None,
1453
+ resolutionStatus: Optional[str] = None,
1454
+ sort: Optional[str] = None,
1455
+ cursor: Optional[str] = None,
1456
+ limit: Optional[int] = None
1457
+ ) -> Any:
1458
+ """Get inline comments for a Confluence page.
1459
+
1460
+ Args:
1461
+ cloudId: Cloud instance ID (required)
1462
+ pageId: The ID of the page (required)
1463
+ status: Filter by status
1464
+ resolutionStatus: Filter by resolution status
1465
+ sort: Sort order
1466
+ cursor: Pagination cursor
1467
+ limit: Maximum results
1468
+
1469
+ Returns:
1470
+ List of inline comments
1471
+ """
1472
+ async with httpx.AsyncClient() as client:
1473
+ r = await client.get(
1474
+ f"{ATLASSIAN_API}/api/issues/{pageId}/comments",
1475
+ params={"limit": limit or 25},
1476
+ headers=_headers()
1477
+ )
1478
+ r.raise_for_status()
1479
+ data = r.json()
1480
+
1481
+ comments_list = data if isinstance(data, list) else data.get("items", [])
1482
+ comments = []
1483
+ for item in comments_list:
1484
+ comments.append({
1485
+ "id": item.get("id"),
1486
+ "type": "inline",
1487
+ "body": {"storage": {"value": item.get("body", ""), "representation": "storage"}},
1488
+ "authorId": item.get("author", {}).get("id"),
1489
+ "createdAt": item.get("createdAt"),
1490
+ "inlineProperties": {"textSelection": item.get("textSelection", "")}
1491
+ })
1492
+
1493
+ return {"results": comments, "_links": {}}
1494
+
1495
+
1496
+ @mcp.tool()
1497
+ async def searchConfluenceUsingCql(
1498
+ cloudId: str,
1499
+ cql: str,
1500
+ cqlcontext: Optional[str] = None,
1501
+ cursor: Optional[str] = None,
1502
+ limit: Optional[int] = None,
1503
+ expand: Optional[str] = None,
1504
+ next: Optional[bool] = None,
1505
+ prev: Optional[bool] = None
1506
+ ) -> Any:
1507
+ """Search Confluence using CQL (only use when CQL is specifically mentioned).
1508
+
1509
+ Args:
1510
+ cloudId: Cloud instance ID (required)
1511
+ cql: CQL query string (required)
1512
+ cqlcontext: CQL context
1513
+ cursor: Pagination cursor
1514
+ limit: Maximum results
1515
+ expand: Fields to expand
1516
+ next: Get next page
1517
+ prev: Get previous page
1518
+
1519
+ Returns:
1520
+ Search results matching the CQL query
1521
+ """
1522
+ params = {"q": cql, "limit": limit or 25}
1523
+
1524
+ async with httpx.AsyncClient() as client:
1525
+ r = await client.get(
1526
+ f"{ATLASSIAN_API}/api/issues/search",
1527
+ params=params,
1528
+ headers=_headers()
1529
+ )
1530
+ r.raise_for_status()
1531
+ data = r.json()
1532
+
1533
+ results = []
1534
+ for item in data.get("items", []):
1535
+ results.append({
1536
+ "content": {
1537
+ "id": item.get("id"),
1538
+ "type": "page",
1539
+ "title": item.get("title"),
1540
+ "status": "current"
1541
+ },
1542
+ "excerpt": item.get("description", "")[:200] if item.get("description") else ""
1543
+ })
1544
+
1545
+ return {"results": results, "cqlQuery": cql, "_links": {}}
1546
+
1547
+
1548
+ # ============================================================================
1549
+ # Server Entry Point
1550
+ # ============================================================================
1551
+
1552
+ if __name__ == "__main__":
1553
+ port = int(os.getenv("PORT") or _port_from_registry(8862))
1554
+ mcp.run(transport="http", host="0.0.0.0", port=port)