flowent 0.0.1 → 0.0.4

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 (493) hide show
  1. package/README.md +19 -8
  2. package/backend/.python-version +1 -0
  3. package/backend/pyproject.toml +57 -0
  4. package/backend/src/flowent/__init__.py +3 -0
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/stats_service.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/_version.py +7 -0
  32. package/backend/src/flowent/access.py +247 -0
  33. package/backend/src/flowent/agent.py +2808 -0
  34. package/backend/src/flowent/assistant_commands.py +106 -0
  35. package/backend/src/flowent/channels/__init__.py +3 -0
  36. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/telegram.py +615 -0
  39. package/backend/src/flowent/cli.py +85 -0
  40. package/backend/src/flowent/config.py +14 -0
  41. package/backend/src/flowent/dev.py +3 -0
  42. package/backend/src/flowent/events.py +157 -0
  43. package/backend/src/flowent/graph_runtime.py +60 -0
  44. package/backend/src/flowent/graph_service.py +1346 -0
  45. package/backend/src/flowent/image_assets.py +356 -0
  46. package/backend/src/flowent/logging.py +155 -0
  47. package/backend/src/flowent/main.py +124 -0
  48. package/backend/src/flowent/mcp_service.py +1904 -0
  49. package/backend/src/flowent/model_metadata.py +98 -0
  50. package/backend/src/flowent/models/__init__.py +121 -0
  51. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/agent.py +33 -0
  65. package/backend/src/flowent/models/base.py +24 -0
  66. package/backend/src/flowent/models/blueprint.py +176 -0
  67. package/backend/src/flowent/models/content.py +164 -0
  68. package/backend/src/flowent/models/delta.py +44 -0
  69. package/backend/src/flowent/models/event.py +51 -0
  70. package/backend/src/flowent/models/graph.py +437 -0
  71. package/backend/src/flowent/models/history.py +214 -0
  72. package/backend/src/flowent/models/llm.py +61 -0
  73. package/backend/src/flowent/models/message.py +27 -0
  74. package/backend/src/flowent/models/tab.py +48 -0
  75. package/backend/src/flowent/models/todo.py +10 -0
  76. package/backend/src/flowent/network.py +146 -0
  77. package/backend/src/flowent/prompts/__init__.py +67 -0
  78. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/common.py +250 -0
  82. package/backend/src/flowent/prompts/steward.py +64 -0
  83. package/backend/src/flowent/providers/__init__.py +23 -0
  84. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/anthropic.py +468 -0
  99. package/backend/src/flowent/providers/base_url.py +60 -0
  100. package/backend/src/flowent/providers/configuration.py +182 -0
  101. package/backend/src/flowent/providers/content.py +122 -0
  102. package/backend/src/flowent/providers/errors.py +223 -0
  103. package/backend/src/flowent/providers/gateway.py +169 -0
  104. package/backend/src/flowent/providers/gemini.py +447 -0
  105. package/backend/src/flowent/providers/headers.py +20 -0
  106. package/backend/src/flowent/providers/management.py +96 -0
  107. package/backend/src/flowent/providers/ollama.py +293 -0
  108. package/backend/src/flowent/providers/openai.py +422 -0
  109. package/backend/src/flowent/providers/openai_responses.py +655 -0
  110. package/backend/src/flowent/providers/registry.py +144 -0
  111. package/backend/src/flowent/providers/sse.py +31 -0
  112. package/backend/src/flowent/providers/thinking.py +79 -0
  113. package/backend/src/flowent/registry.py +73 -0
  114. package/backend/src/flowent/role_management.py +255 -0
  115. package/backend/src/flowent/routes/__init__.py +30 -0
  116. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  117. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/access.py +48 -0
  131. package/backend/src/flowent/routes/assistant.py +155 -0
  132. package/backend/src/flowent/routes/image_assets.py +33 -0
  133. package/backend/src/flowent/routes/mcp.py +125 -0
  134. package/backend/src/flowent/routes/meta.py +28 -0
  135. package/backend/src/flowent/routes/nodes.py +365 -0
  136. package/backend/src/flowent/routes/prompts.py +46 -0
  137. package/backend/src/flowent/routes/providers_route.py +364 -0
  138. package/backend/src/flowent/routes/roles.py +207 -0
  139. package/backend/src/flowent/routes/settings.py +324 -0
  140. package/backend/src/flowent/routes/stats.py +229 -0
  141. package/backend/src/flowent/routes/tabs.py +292 -0
  142. package/backend/src/flowent/routes/ws.py +33 -0
  143. package/backend/src/flowent/runtime.py +188 -0
  144. package/backend/src/flowent/sandbox.py +45 -0
  145. package/backend/src/flowent/security.py +42 -0
  146. package/backend/src/flowent/settings.py +2467 -0
  147. package/backend/src/flowent/settings_management.py +286 -0
  148. package/backend/src/flowent/state_db.py +120 -0
  149. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
  150. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
  151. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
  152. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
  153. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
  154. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
  155. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
  156. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
  157. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
  158. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
  159. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
  160. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  161. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  162. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
  163. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
  164. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
  165. package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
  166. package/backend/src/flowent/static/assets/dialog-BeGSweF6.js +1 -0
  167. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  168. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  169. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  170. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +1 -0
  171. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
  172. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  173. package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
  174. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
  175. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  176. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
  177. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  178. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
  179. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
  180. package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
  181. package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
  182. package/backend/src/flowent/static/index.html +36 -0
  183. package/backend/src/flowent/stats_service.py +218 -0
  184. package/backend/src/flowent/tools/__init__.py +201 -0
  185. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  186. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  205. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  206. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  207. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  208. package/backend/src/flowent/tools/connect.py +156 -0
  209. package/backend/src/flowent/tools/contacts.py +22 -0
  210. package/backend/src/flowent/tools/create_agent.py +270 -0
  211. package/backend/src/flowent/tools/create_tab.py +59 -0
  212. package/backend/src/flowent/tools/delete_tab.py +39 -0
  213. package/backend/src/flowent/tools/edit.py +142 -0
  214. package/backend/src/flowent/tools/exec.py +117 -0
  215. package/backend/src/flowent/tools/fetch.py +85 -0
  216. package/backend/src/flowent/tools/idle.py +27 -0
  217. package/backend/src/flowent/tools/list_roles.py +50 -0
  218. package/backend/src/flowent/tools/list_tabs.py +96 -0
  219. package/backend/src/flowent/tools/list_tools.py +24 -0
  220. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  221. package/backend/src/flowent/tools/manage_providers.py +220 -0
  222. package/backend/src/flowent/tools/manage_roles.py +275 -0
  223. package/backend/src/flowent/tools/manage_settings.py +346 -0
  224. package/backend/src/flowent/tools/mcp.py +199 -0
  225. package/backend/src/flowent/tools/read.py +152 -0
  226. package/backend/src/flowent/tools/send.py +50 -0
  227. package/backend/src/flowent/tools/set_permissions.py +84 -0
  228. package/backend/src/flowent/tools/sleep.py +41 -0
  229. package/backend/src/flowent/tools/todo.py +51 -0
  230. package/backend/src/flowent/workspace_store.py +479 -0
  231. package/backend/tests/__init__.py +0 -0
  232. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  233. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  234. package/backend/tests/conftest.py +6 -0
  235. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  236. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  242. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  243. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  244. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  245. package/backend/tests/integration/api/conftest.py +29 -0
  246. package/backend/tests/integration/api/test_access_api.py +182 -0
  247. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  248. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  249. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  250. package/backend/tests/integration/api/test_meta_api.py +33 -0
  251. package/backend/tests/integration/api/test_nodes_api.py +486 -0
  252. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  253. package/backend/tests/integration/api/test_roles_api.py +227 -0
  254. package/backend/tests/integration/api/test_tabs_api.py +501 -0
  255. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  256. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  261. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/unit/agent/test_agent_public_api.py +746 -0
  264. package/backend/tests/unit/agent/test_agent_runtime.py +2726 -0
  265. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  267. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/unit/logging/test_logging.py +132 -0
  269. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/unit/prompts/test_prompts.py +569 -0
  271. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  272. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  275. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  276. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  277. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  278. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  279. package/backend/tests/unit/providers/test_errors.py +68 -0
  280. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  281. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  282. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  283. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  284. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  285. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  288. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  289. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  291. package/backend/tests/unit/routes/test_providers_route.py +368 -0
  292. package/backend/tests/unit/routes/test_roles_routes.py +426 -0
  293. package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
  294. package/backend/tests/unit/routes/test_stats_routes.py +149 -0
  295. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1012 -0
  297. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  299. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/security/test_security.py +110 -0
  301. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/settings/test_settings_roles.py +711 -0
  303. package/backend/tests/unit/test_access.py +45 -0
  304. package/backend/tests/unit/test_cli.py +124 -0
  305. package/backend/tests/unit/test_graph_runtime.py +72 -0
  306. package/backend/tests/unit/test_network.py +51 -0
  307. package/backend/tests/unit/test_state_sqlite_storage.py +93 -0
  308. package/backend/tests/unit/test_workspace_store.py +231 -0
  309. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  319. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  321. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  323. package/backend/tests/unit/tools/test_connect_tool.py +229 -0
  324. package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
  325. package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -0
  326. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  327. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  328. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  329. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  330. package/backend/tests/unit/tools/test_manage_providers_tool.py +458 -0
  331. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  332. package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
  333. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  334. package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
  335. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  336. package/backend/tests/unit/tools/test_tool_registry.py +91 -0
  337. package/backend/uv.lock +1144 -0
  338. package/bin/flowent.mjs +62 -36
  339. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
  340. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
  341. package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
  342. package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
  343. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
  344. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
  345. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
  346. package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
  347. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
  348. package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
  349. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
  350. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  351. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  352. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
  353. package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
  354. package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
  355. package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
  356. package/dist/frontend/assets/dialog-BeGSweF6.js +1 -0
  357. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  358. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  359. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  360. package/dist/frontend/assets/index-BHC1Vhy8.css +1 -0
  361. package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
  362. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  363. package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
  364. package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
  365. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  366. package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
  367. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  368. package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
  369. package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
  370. package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
  371. package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
  372. package/dist/frontend/index.html +36 -0
  373. package/package.json +27 -41
  374. package/dist/.next/BUILD_ID +0 -1
  375. package/dist/.next/app-path-routes-manifest.json +0 -6
  376. package/dist/.next/build-manifest.json +0 -20
  377. package/dist/.next/package.json +0 -1
  378. package/dist/.next/prerender-manifest.json +0 -114
  379. package/dist/.next/required-server-files.json +0 -333
  380. package/dist/.next/routes-manifest.json +0 -69
  381. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  382. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  383. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  384. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  385. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  386. package/dist/.next/server/app/_global-error/page.js +0 -9
  387. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  388. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  389. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  390. package/dist/.next/server/app/_global-error.html +0 -1
  391. package/dist/.next/server/app/_global-error.meta +0 -15
  392. package/dist/.next/server/app/_global-error.rsc +0 -14
  393. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  394. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  395. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  396. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  397. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  398. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  399. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  400. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  401. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  402. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  403. package/dist/.next/server/app/_not-found/page.js +0 -13
  404. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  405. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  406. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  407. package/dist/.next/server/app/_not-found.html +0 -1
  408. package/dist/.next/server/app/_not-found.meta +0 -16
  409. package/dist/.next/server/app/_not-found.rsc +0 -16
  410. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  411. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  412. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  413. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  414. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  415. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  416. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  417. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  418. package/dist/.next/server/app/icon.svg/route.js +0 -6
  419. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  420. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  421. package/dist/.next/server/app/icon.svg.meta +0 -1
  422. package/dist/.next/server/app/index.html +0 -1
  423. package/dist/.next/server/app/index.meta +0 -14
  424. package/dist/.next/server/app/index.rsc +0 -15
  425. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  426. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  427. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  428. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  429. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  430. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  431. package/dist/.next/server/app/page/build-manifest.json +0 -16
  432. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  433. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  434. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  435. package/dist/.next/server/app/page.js +0 -14
  436. package/dist/.next/server/app/page.js.map +0 -5
  437. package/dist/.next/server/app/page.js.nft.json +0 -1
  438. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  439. package/dist/.next/server/app-paths-manifest.json +0 -6
  440. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  441. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  442. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  443. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  444. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  445. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  446. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  447. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  448. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  449. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  450. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  451. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  452. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  453. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  454. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  455. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  456. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  457. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  458. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  459. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  460. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  461. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  462. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  463. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  464. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  465. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  466. package/dist/.next/server/functions-config-manifest.json +0 -4
  467. package/dist/.next/server/middleware-build-manifest.js +0 -20
  468. package/dist/.next/server/middleware-manifest.json +0 -6
  469. package/dist/.next/server/next-font-manifest.js +0 -1
  470. package/dist/.next/server/next-font-manifest.json +0 -13
  471. package/dist/.next/server/pages/404.html +0 -1
  472. package/dist/.next/server/pages/500.html +0 -1
  473. package/dist/.next/server/pages-manifest.json +0 -4
  474. package/dist/.next/server/prefetch-hints.json +0 -1
  475. package/dist/.next/server/server-reference-manifest.js +0 -1
  476. package/dist/.next/server/server-reference-manifest.json +0 -5
  477. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
  478. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
  479. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
  480. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  481. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  482. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  483. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  484. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  485. package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
  486. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  487. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  488. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  489. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  490. package/dist/package.json +0 -88
  491. package/dist/server.js +0 -38
  492. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  493. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,1904 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import queue
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from collections import deque
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from curl_cffi import requests as curl_requests
17
+
18
+ from flowent.settings import (
19
+ MCPServerConfig,
20
+ find_mcp_server,
21
+ get_settings,
22
+ save_settings,
23
+ )
24
+ from flowent.state_db import open_state_db
25
+
26
+ if TYPE_CHECKING:
27
+ from flowent.agent import Agent
28
+
29
+ PROTOCOL_VERSION = "2025-06-18"
30
+ ACTIVITY_RETENTION_SECONDS = 30 * 24 * 60 * 60
31
+
32
+
33
+ class MCPError(RuntimeError):
34
+ pass
35
+
36
+
37
+ @dataclass
38
+ class MCPToolDescriptor:
39
+ server_name: str
40
+ tool_name: str
41
+ fully_qualified_id: str
42
+ title: str | None = None
43
+ description: str = ""
44
+ input_schema: dict[str, Any] = field(default_factory=dict)
45
+ read_only_hint: bool = False
46
+ destructive_hint: bool = False
47
+ open_world_hint: bool = False
48
+
49
+ def serialize(self) -> dict[str, object]:
50
+ return {
51
+ "name": self.fully_qualified_id,
52
+ "source": "mcp",
53
+ "server_name": self.server_name,
54
+ "tool_name": self.tool_name,
55
+ "fully_qualified_id": self.fully_qualified_id,
56
+ "title": self.title,
57
+ "description": self.description,
58
+ "parameters": self.input_schema,
59
+ "read_only_hint": self.read_only_hint,
60
+ "destructive_hint": self.destructive_hint,
61
+ "open_world_hint": self.open_world_hint,
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class MCPResourceDescriptor:
67
+ server_name: str
68
+ name: str
69
+ uri: str
70
+ mime_type: str | None = None
71
+ description: str | None = None
72
+
73
+ def serialize(self) -> dict[str, object]:
74
+ return {
75
+ "server_name": self.server_name,
76
+ "name": self.name,
77
+ "uri": self.uri,
78
+ "mime_type": self.mime_type,
79
+ "description": self.description,
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class MCPResourceTemplateDescriptor:
85
+ server_name: str
86
+ name: str
87
+ uri_template: str
88
+ description: str | None = None
89
+
90
+ def serialize(self) -> dict[str, object]:
91
+ return {
92
+ "server_name": self.server_name,
93
+ "name": self.name,
94
+ "uri_template": self.uri_template,
95
+ "description": self.description,
96
+ }
97
+
98
+
99
+ @dataclass
100
+ class MCPPromptDescriptor:
101
+ server_name: str
102
+ name: str
103
+ description: str | None = None
104
+ arguments: list[dict[str, object]] = field(default_factory=list)
105
+
106
+ def serialize(self) -> dict[str, object]:
107
+ return {
108
+ "server_name": self.server_name,
109
+ "name": self.name,
110
+ "description": self.description,
111
+ "arguments": list(self.arguments),
112
+ }
113
+
114
+
115
+ @dataclass
116
+ class MCPDiscoverySnapshot:
117
+ server_name: str
118
+ transport: str
119
+ status: str
120
+ auth_status: str
121
+ last_auth_result: str | None = None
122
+ last_refresh_at: float | None = None
123
+ last_refresh_result: str = "never"
124
+ last_error: str | None = None
125
+ tools: list[MCPToolDescriptor] = field(default_factory=list)
126
+ resources: list[MCPResourceDescriptor] = field(default_factory=list)
127
+ resource_templates: list[MCPResourceTemplateDescriptor] = field(
128
+ default_factory=list
129
+ )
130
+ prompts: list[MCPPromptDescriptor] = field(default_factory=list)
131
+
132
+ def serialize(self) -> dict[str, object]:
133
+ return {
134
+ "server_name": self.server_name,
135
+ "transport": self.transport,
136
+ "status": self.status,
137
+ "auth_status": self.auth_status,
138
+ "last_auth_result": self.last_auth_result,
139
+ "last_refresh_at": self.last_refresh_at,
140
+ "last_refresh_result": self.last_refresh_result,
141
+ "last_error": self.last_error,
142
+ "tools": [item.serialize() for item in self.tools],
143
+ "resources": [item.serialize() for item in self.resources],
144
+ "resource_templates": [
145
+ item.serialize() for item in self.resource_templates
146
+ ],
147
+ "prompts": [item.serialize() for item in self.prompts],
148
+ "capability_counts": {
149
+ "tools": len(self.tools),
150
+ "resources": len(self.resources),
151
+ "resource_templates": len(self.resource_templates),
152
+ "prompts": len(self.prompts),
153
+ },
154
+ }
155
+
156
+
157
+ @dataclass
158
+ class MCPActivityRecord:
159
+ id: str
160
+ server_name: str
161
+ action: str
162
+ actor_node_id: str | None
163
+ tab_id: str | None
164
+ started_at: float
165
+ ended_at: float
166
+ result: str
167
+ summary: str
168
+ tool_name: str | None = None
169
+ fully_qualified_id: str | None = None
170
+ target: str | None = None
171
+ approval_result: str | None = None
172
+
173
+ def serialize(self) -> dict[str, object]:
174
+ return {
175
+ "id": self.id,
176
+ "server_name": self.server_name,
177
+ "action": self.action,
178
+ "actor_node_id": self.actor_node_id,
179
+ "tab_id": self.tab_id,
180
+ "started_at": self.started_at,
181
+ "ended_at": self.ended_at,
182
+ "duration_ms": max(0.0, (self.ended_at - self.started_at) * 1000),
183
+ "result": self.result,
184
+ "summary": self.summary,
185
+ "tool_name": self.tool_name,
186
+ "fully_qualified_id": self.fully_qualified_id,
187
+ "target": self.target,
188
+ "approval_result": self.approval_result,
189
+ }
190
+
191
+
192
+ def _deserialize_tool_descriptor(data: dict[str, object]) -> MCPToolDescriptor | None:
193
+ server_name = data.get("server_name")
194
+ tool_name = data.get("tool_name")
195
+ fully_qualified_id = data.get("fully_qualified_id")
196
+ if (
197
+ not isinstance(server_name, str)
198
+ or not isinstance(tool_name, str)
199
+ or not isinstance(fully_qualified_id, str)
200
+ ):
201
+ return None
202
+ title = data.get("title")
203
+ description = data.get("description")
204
+ parameters = data.get("parameters")
205
+ return MCPToolDescriptor(
206
+ server_name=server_name,
207
+ tool_name=tool_name,
208
+ fully_qualified_id=fully_qualified_id,
209
+ title=title if isinstance(title, str) else None,
210
+ description=description if isinstance(description, str) else "",
211
+ input_schema=parameters if isinstance(parameters, dict) else {},
212
+ read_only_hint=bool(data.get("read_only_hint", False)),
213
+ destructive_hint=bool(data.get("destructive_hint", False)),
214
+ open_world_hint=bool(data.get("open_world_hint", False)),
215
+ )
216
+
217
+
218
+ def _deserialize_resource_descriptor(
219
+ data: dict[str, object],
220
+ ) -> MCPResourceDescriptor | None:
221
+ server_name = data.get("server_name")
222
+ name = data.get("name")
223
+ uri = data.get("uri")
224
+ if (
225
+ not isinstance(server_name, str)
226
+ or not isinstance(name, str)
227
+ or not isinstance(uri, str)
228
+ ):
229
+ return None
230
+ mime_type = data.get("mime_type")
231
+ description = data.get("description")
232
+ return MCPResourceDescriptor(
233
+ server_name=server_name,
234
+ name=name,
235
+ uri=uri,
236
+ mime_type=mime_type if isinstance(mime_type, str) else None,
237
+ description=description if isinstance(description, str) else None,
238
+ )
239
+
240
+
241
+ def _deserialize_resource_template_descriptor(
242
+ data: dict[str, object],
243
+ ) -> MCPResourceTemplateDescriptor | None:
244
+ server_name = data.get("server_name")
245
+ name = data.get("name")
246
+ uri_template = data.get("uri_template")
247
+ if (
248
+ not isinstance(server_name, str)
249
+ or not isinstance(name, str)
250
+ or not isinstance(uri_template, str)
251
+ ):
252
+ return None
253
+ description = data.get("description")
254
+ return MCPResourceTemplateDescriptor(
255
+ server_name=server_name,
256
+ name=name,
257
+ uri_template=uri_template,
258
+ description=description if isinstance(description, str) else None,
259
+ )
260
+
261
+
262
+ def _deserialize_prompt_descriptor(
263
+ data: dict[str, object],
264
+ ) -> MCPPromptDescriptor | None:
265
+ server_name = data.get("server_name")
266
+ name = data.get("name")
267
+ if not isinstance(server_name, str) or not isinstance(name, str):
268
+ return None
269
+ description = data.get("description")
270
+ arguments = data.get("arguments")
271
+ return MCPPromptDescriptor(
272
+ server_name=server_name,
273
+ name=name,
274
+ description=description if isinstance(description, str) else None,
275
+ arguments=[
276
+ dict(argument) for argument in arguments if isinstance(argument, dict)
277
+ ]
278
+ if isinstance(arguments, list)
279
+ else [],
280
+ )
281
+
282
+
283
+ def _snapshot_from_mapping(data: dict[str, object]) -> MCPDiscoverySnapshot | None:
284
+ server_name = data.get("server_name")
285
+ transport = data.get("transport")
286
+ status = data.get("status")
287
+ auth_status = data.get("auth_status")
288
+ if (
289
+ not isinstance(server_name, str)
290
+ or not isinstance(transport, str)
291
+ or not isinstance(status, str)
292
+ or not isinstance(auth_status, str)
293
+ ):
294
+ return None
295
+ raw_tools = data.get("tools")
296
+ raw_resources = data.get("resources")
297
+ raw_resource_templates = data.get("resource_templates")
298
+ raw_prompts = data.get("prompts")
299
+ raw_last_auth_result = data.get("last_auth_result")
300
+ raw_last_refresh_at = data.get("last_refresh_at")
301
+ raw_last_refresh_result = data.get("last_refresh_result")
302
+ raw_last_error = data.get("last_error")
303
+ tools = (
304
+ [
305
+ descriptor
306
+ for descriptor in (
307
+ _deserialize_tool_descriptor(item)
308
+ for item in raw_tools
309
+ if isinstance(item, dict)
310
+ )
311
+ if descriptor is not None
312
+ ]
313
+ if isinstance(raw_tools, list)
314
+ else []
315
+ )
316
+ resources = (
317
+ [
318
+ descriptor
319
+ for descriptor in (
320
+ _deserialize_resource_descriptor(item)
321
+ for item in raw_resources
322
+ if isinstance(item, dict)
323
+ )
324
+ if descriptor is not None
325
+ ]
326
+ if isinstance(raw_resources, list)
327
+ else []
328
+ )
329
+ resource_templates = (
330
+ [
331
+ descriptor
332
+ for descriptor in (
333
+ _deserialize_resource_template_descriptor(item)
334
+ for item in raw_resource_templates
335
+ if isinstance(item, dict)
336
+ )
337
+ if descriptor is not None
338
+ ]
339
+ if isinstance(raw_resource_templates, list)
340
+ else []
341
+ )
342
+ prompts = (
343
+ [
344
+ descriptor
345
+ for descriptor in (
346
+ _deserialize_prompt_descriptor(item)
347
+ for item in raw_prompts
348
+ if isinstance(item, dict)
349
+ )
350
+ if descriptor is not None
351
+ ]
352
+ if isinstance(raw_prompts, list)
353
+ else []
354
+ )
355
+ return MCPDiscoverySnapshot(
356
+ server_name=server_name,
357
+ transport=transport,
358
+ status=status,
359
+ auth_status=auth_status,
360
+ last_auth_result=(
361
+ raw_last_auth_result if isinstance(raw_last_auth_result, str) else None
362
+ ),
363
+ last_refresh_at=(
364
+ float(raw_last_refresh_at)
365
+ if isinstance(raw_last_refresh_at, (int, float))
366
+ else None
367
+ ),
368
+ last_refresh_result=(
369
+ raw_last_refresh_result
370
+ if isinstance(raw_last_refresh_result, str)
371
+ else "never"
372
+ ),
373
+ last_error=raw_last_error if isinstance(raw_last_error, str) else None,
374
+ tools=tools,
375
+ resources=resources,
376
+ resource_templates=resource_templates,
377
+ prompts=prompts,
378
+ )
379
+
380
+
381
+ def _activity_from_mapping(data: dict[str, object]) -> MCPActivityRecord | None:
382
+ record_id = data.get("id")
383
+ server_name = data.get("server_name")
384
+ action = data.get("action")
385
+ started_at = data.get("started_at")
386
+ ended_at = data.get("ended_at")
387
+ result = data.get("result")
388
+ summary = data.get("summary")
389
+ if (
390
+ not isinstance(record_id, str)
391
+ or not isinstance(server_name, str)
392
+ or not isinstance(action, str)
393
+ or not isinstance(started_at, (int, float))
394
+ or not isinstance(ended_at, (int, float))
395
+ or not isinstance(result, str)
396
+ or not isinstance(summary, str)
397
+ ):
398
+ return None
399
+ actor_node_id = data.get("actor_node_id")
400
+ tab_id = data.get("tab_id")
401
+ tool_name = data.get("tool_name")
402
+ fully_qualified_id = data.get("fully_qualified_id")
403
+ target = data.get("target")
404
+ approval_result = data.get("approval_result")
405
+ return MCPActivityRecord(
406
+ id=record_id,
407
+ server_name=server_name,
408
+ action=action,
409
+ actor_node_id=actor_node_id if isinstance(actor_node_id, str) else None,
410
+ tab_id=tab_id if isinstance(tab_id, str) else None,
411
+ started_at=float(started_at),
412
+ ended_at=float(ended_at),
413
+ result=result,
414
+ summary=summary,
415
+ tool_name=tool_name if isinstance(tool_name, str) else None,
416
+ fully_qualified_id=(
417
+ fully_qualified_id if isinstance(fully_qualified_id, str) else None
418
+ ),
419
+ target=target if isinstance(target, str) else None,
420
+ approval_result=approval_result if isinstance(approval_result, str) else None,
421
+ )
422
+
423
+
424
+ def _escape_identifier(value: str) -> str:
425
+ parts: list[str] = []
426
+ for character in value:
427
+ if character.isalnum():
428
+ parts.append(character.lower())
429
+ continue
430
+ parts.append(f"_{ord(character):02x}_")
431
+ return "".join(parts) or "unnamed"
432
+
433
+
434
+ def build_fully_qualified_tool_id(server_name: str, tool_name: str) -> str:
435
+ return (
436
+ "mcp__" + _escape_identifier(server_name) + "__" + _escape_identifier(tool_name)
437
+ )
438
+
439
+
440
+ def _build_root_uri(path: str) -> str:
441
+ from flowent.settings import resolve_path
442
+
443
+ return resolve_path(path).as_uri()
444
+
445
+
446
+ def _build_roots_for_agent(agent: Agent) -> list[dict[str, str]]:
447
+ from flowent.settings import get_runtime_working_dir_path, resolve_path
448
+
449
+ workspace_root = str(get_runtime_working_dir_path())
450
+ if agent.config.node_type.value == "assistant":
451
+ boundary_dirs = list(get_settings().assistant.write_dirs)
452
+ else:
453
+ boundary_dirs = list(agent.config.write_dirs)
454
+ ordered_paths: list[str] = []
455
+ seen: set[str] = set()
456
+ for path in [workspace_root, *boundary_dirs]:
457
+ resolved = str(resolve_path(path))
458
+ if resolved in seen:
459
+ continue
460
+ seen.add(resolved)
461
+ ordered_paths.append(resolved)
462
+ return [
463
+ {
464
+ "name": Path(path).name or path,
465
+ "uri": _build_root_uri(path),
466
+ }
467
+ for path in ordered_paths
468
+ ]
469
+
470
+
471
+ def _build_stdio_env(server: MCPServerConfig) -> dict[str, str]:
472
+ env = dict(os.environ)
473
+ env.update(server.env)
474
+ for env_var_name in server.env_vars:
475
+ value = os.environ.get(env_var_name)
476
+ if value is not None:
477
+ env[env_var_name] = value
478
+ return env
479
+
480
+
481
+ def _build_http_headers(server: MCPServerConfig) -> tuple[dict[str, str], str | None]:
482
+ headers = dict(server.http_headers)
483
+ bearer_token = None
484
+ if server.bearer_token_env_var:
485
+ bearer_token = os.environ.get(server.bearer_token_env_var)
486
+ if bearer_token:
487
+ headers["Authorization"] = f"Bearer {bearer_token}"
488
+ for env_header_name in server.env_http_headers:
489
+ env_value = os.environ.get(env_header_name)
490
+ if not env_value:
491
+ continue
492
+ if ":" in env_value:
493
+ key, value = env_value.split(":", 1)
494
+ headers[key.strip()] = value.strip()
495
+ else:
496
+ headers[env_header_name] = env_value
497
+ return headers, bearer_token
498
+
499
+
500
+ def _parse_tool_descriptor(
501
+ server_name: str, raw_tool: object
502
+ ) -> MCPToolDescriptor | None:
503
+ if not isinstance(raw_tool, dict):
504
+ return None
505
+ tool_name = raw_tool.get("name")
506
+ if not isinstance(tool_name, str) or not tool_name.strip():
507
+ return None
508
+ raw_annotations = raw_tool.get("annotations")
509
+ annotations: dict[str, Any] = (
510
+ raw_annotations if isinstance(raw_annotations, dict) else {}
511
+ )
512
+ title = raw_tool.get("title")
513
+ description = raw_tool.get("description")
514
+ input_schema = raw_tool.get("inputSchema")
515
+ return MCPToolDescriptor(
516
+ server_name=server_name,
517
+ tool_name=tool_name.strip(),
518
+ fully_qualified_id=build_fully_qualified_tool_id(
519
+ server_name, tool_name.strip()
520
+ ),
521
+ title=title.strip() if isinstance(title, str) and title.strip() else None,
522
+ description=description.strip() if isinstance(description, str) else "",
523
+ input_schema=input_schema if isinstance(input_schema, dict) else {},
524
+ read_only_hint=bool(
525
+ annotations.get("readOnlyHint", raw_tool.get("readOnlyHint", False))
526
+ ),
527
+ destructive_hint=bool(
528
+ annotations.get(
529
+ "destructiveHint",
530
+ raw_tool.get("destructiveHint", False),
531
+ )
532
+ ),
533
+ open_world_hint=bool(
534
+ annotations.get("openWorldHint", raw_tool.get("openWorldHint", False))
535
+ ),
536
+ )
537
+
538
+
539
+ def _parse_resource_descriptor(
540
+ server_name: str,
541
+ raw_resource: object,
542
+ ) -> MCPResourceDescriptor | None:
543
+ if not isinstance(raw_resource, dict):
544
+ return None
545
+ uri = raw_resource.get("uri")
546
+ name = raw_resource.get("name")
547
+ if not isinstance(uri, str) or not uri.strip():
548
+ return None
549
+ if not isinstance(name, str) or not name.strip():
550
+ name = uri
551
+ mime_type = raw_resource.get("mimeType")
552
+ description = raw_resource.get("description")
553
+ return MCPResourceDescriptor(
554
+ server_name=server_name,
555
+ name=name.strip(),
556
+ uri=uri.strip(),
557
+ mime_type=mime_type.strip()
558
+ if isinstance(mime_type, str) and mime_type.strip()
559
+ else None,
560
+ description=description.strip()
561
+ if isinstance(description, str) and description.strip()
562
+ else None,
563
+ )
564
+
565
+
566
+ def _parse_resource_template_descriptor(
567
+ server_name: str,
568
+ raw_template: object,
569
+ ) -> MCPResourceTemplateDescriptor | None:
570
+ if not isinstance(raw_template, dict):
571
+ return None
572
+ uri_template = raw_template.get("uriTemplate")
573
+ name = raw_template.get("name")
574
+ if not isinstance(uri_template, str) or not uri_template.strip():
575
+ return None
576
+ if not isinstance(name, str) or not name.strip():
577
+ name = uri_template
578
+ description = raw_template.get("description")
579
+ return MCPResourceTemplateDescriptor(
580
+ server_name=server_name,
581
+ name=name.strip(),
582
+ uri_template=uri_template.strip(),
583
+ description=description.strip()
584
+ if isinstance(description, str) and description.strip()
585
+ else None,
586
+ )
587
+
588
+
589
+ def _parse_prompt_descriptor(
590
+ server_name: str, raw_prompt: object
591
+ ) -> MCPPromptDescriptor | None:
592
+ if not isinstance(raw_prompt, dict):
593
+ return None
594
+ name = raw_prompt.get("name")
595
+ if not isinstance(name, str) or not name.strip():
596
+ return None
597
+ description = raw_prompt.get("description")
598
+ arguments = raw_prompt.get("arguments")
599
+ return MCPPromptDescriptor(
600
+ server_name=server_name,
601
+ name=name.strip(),
602
+ description=description.strip()
603
+ if isinstance(description, str) and description.strip()
604
+ else None,
605
+ arguments=[
606
+ dict(argument) for argument in arguments if isinstance(argument, dict)
607
+ ]
608
+ if isinstance(arguments, list)
609
+ else [],
610
+ )
611
+
612
+
613
+ class _BaseConnection:
614
+ def __init__(self, server: MCPServerConfig, *, timeout_seconds: int) -> None:
615
+ self.server = server
616
+ self.timeout_seconds = timeout_seconds
617
+ self._next_request_id = 0
618
+
619
+ def _build_request(
620
+ self, method: str, params: dict[str, Any] | None = None
621
+ ) -> dict[str, object]:
622
+ self._next_request_id += 1
623
+ payload: dict[str, object] = {
624
+ "jsonrpc": "2.0",
625
+ "id": self._next_request_id,
626
+ "method": method,
627
+ }
628
+ if params is not None:
629
+ payload["params"] = params
630
+ return payload
631
+
632
+ def _build_notification(
633
+ self, method: str, params: dict[str, Any] | None = None
634
+ ) -> dict[str, object]:
635
+ payload: dict[str, object] = {
636
+ "jsonrpc": "2.0",
637
+ "method": method,
638
+ }
639
+ if params is not None:
640
+ payload["params"] = params
641
+ return payload
642
+
643
+ def initialize(self) -> dict[str, Any]:
644
+ result = self.request(
645
+ "initialize",
646
+ {
647
+ "protocolVersion": PROTOCOL_VERSION,
648
+ "capabilities": {
649
+ "roots": {"listChanged": False},
650
+ },
651
+ "clientInfo": {"name": "Flowent", "version": "dev"},
652
+ },
653
+ )
654
+ self.notify("notifications/initialized")
655
+ return result
656
+
657
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
658
+ self._send(self._build_notification(method, params))
659
+
660
+ def request(
661
+ self, method: str, params: dict[str, Any] | None = None
662
+ ) -> dict[str, Any]:
663
+ payload = self._build_request(method, params)
664
+ request_id = payload["id"]
665
+ self._send(payload)
666
+ while True:
667
+ message = self._receive()
668
+ if not isinstance(message, dict):
669
+ continue
670
+ if message.get("id") == request_id and "result" in message:
671
+ result = message.get("result")
672
+ return result if isinstance(result, dict) else {}
673
+ if message.get("id") == request_id and "error" in message:
674
+ error = message.get("error")
675
+ raise MCPError(
676
+ error.get("message")
677
+ if isinstance(error, dict) and isinstance(error.get("message"), str)
678
+ else f"MCP request failed: {method}"
679
+ )
680
+ if "method" in message and "id" in message:
681
+ self._handle_server_request(message)
682
+
683
+ def close(self) -> None:
684
+ return None
685
+
686
+ def _handle_server_request(self, message: dict[str, Any]) -> None:
687
+ request_id = message.get("id")
688
+ method = message.get("method")
689
+ if not isinstance(request_id, int | str) or not isinstance(method, str):
690
+ return
691
+ if method == "roots/list":
692
+ self._send(
693
+ {
694
+ "jsonrpc": "2.0",
695
+ "id": request_id,
696
+ "result": {
697
+ "roots": self._build_roots(),
698
+ },
699
+ }
700
+ )
701
+ return
702
+ if method == "ping":
703
+ self._send({"jsonrpc": "2.0", "id": request_id, "result": {}})
704
+ return
705
+ self._send(
706
+ {
707
+ "jsonrpc": "2.0",
708
+ "id": request_id,
709
+ "error": {
710
+ "code": -32601,
711
+ "message": f"Unsupported MCP request: {method}",
712
+ },
713
+ }
714
+ )
715
+
716
+ def _build_roots(self) -> list[dict[str, str]]:
717
+ return []
718
+
719
+ def _send(self, payload: dict[str, object]) -> None:
720
+ raise NotImplementedError
721
+
722
+ def _receive(self) -> dict[str, Any]:
723
+ raise NotImplementedError
724
+
725
+
726
+ class _StdioConnection(_BaseConnection):
727
+ def __init__(
728
+ self,
729
+ server: MCPServerConfig,
730
+ *,
731
+ timeout_seconds: int,
732
+ roots: list[dict[str, str]],
733
+ ) -> None:
734
+ super().__init__(server, timeout_seconds=timeout_seconds)
735
+ command: list[str] = [server.command, *server.args]
736
+ self._roots_payload = roots
737
+ self._stderr_lines: deque[str] = deque(maxlen=20)
738
+ self._stdout_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
739
+ try:
740
+ self._process = subprocess.Popen(
741
+ command,
742
+ cwd=server.cwd or None,
743
+ env=_build_stdio_env(server),
744
+ stdin=subprocess.PIPE,
745
+ stdout=subprocess.PIPE,
746
+ stderr=subprocess.PIPE,
747
+ text=True,
748
+ bufsize=1,
749
+ )
750
+ except FileNotFoundError as exc:
751
+ raise MCPError(str(exc)) from exc
752
+ self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
753
+ self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
754
+ self._stdout_thread.start()
755
+ self._stderr_thread.start()
756
+
757
+ def _build_roots(self) -> list[dict[str, str]]:
758
+ return list(self._roots_payload)
759
+
760
+ def _read_stdout(self) -> None:
761
+ assert self._process.stdout is not None
762
+ for line in self._process.stdout:
763
+ stripped = line.strip()
764
+ if not stripped:
765
+ continue
766
+ try:
767
+ payload = json.loads(stripped)
768
+ except json.JSONDecodeError:
769
+ self._stdout_queue.put(
770
+ {
771
+ "jsonrpc": "2.0",
772
+ "error": {
773
+ "message": f"Invalid MCP response: {stripped[:200]}",
774
+ },
775
+ }
776
+ )
777
+ continue
778
+ if isinstance(payload, dict):
779
+ self._stdout_queue.put(payload)
780
+ self._stdout_queue.put(None)
781
+
782
+ def _read_stderr(self) -> None:
783
+ assert self._process.stderr is not None
784
+ for line in self._process.stderr:
785
+ stripped = line.strip()
786
+ if stripped:
787
+ self._stderr_lines.append(stripped)
788
+
789
+ def _send(self, payload: dict[str, object]) -> None:
790
+ if self._process.stdin is None:
791
+ raise MCPError("MCP stdio connection is unavailable")
792
+ self._process.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
793
+ self._process.stdin.flush()
794
+
795
+ def _receive(self) -> dict[str, Any]:
796
+ try:
797
+ message = self._stdout_queue.get(timeout=self.timeout_seconds)
798
+ except queue.Empty as exc:
799
+ stderr_tail = "\n".join(self._stderr_lines)
800
+ raise MCPError(
801
+ stderr_tail
802
+ or f"MCP stdio request timed out after {self.timeout_seconds}s"
803
+ ) from exc
804
+ if message is None:
805
+ stderr_tail = "\n".join(self._stderr_lines)
806
+ raise MCPError(stderr_tail or "MCP stdio server closed the connection")
807
+ return message
808
+
809
+ def close(self) -> None:
810
+ if self._process.poll() is None:
811
+ self._process.terminate()
812
+ try:
813
+ self._process.wait(timeout=1)
814
+ except subprocess.TimeoutExpired:
815
+ self._process.kill()
816
+
817
+
818
+ class _HttpConnection(_BaseConnection):
819
+ def __init__(
820
+ self,
821
+ server: MCPServerConfig,
822
+ *,
823
+ timeout_seconds: int,
824
+ ) -> None:
825
+ super().__init__(server, timeout_seconds=timeout_seconds)
826
+ headers, _ = _build_http_headers(server)
827
+ self._headers: dict[str, str] = headers
828
+ self._client: Any = curl_requests.Session()
829
+ self._pending_response: dict[str, Any] | list[dict[str, Any]] = {}
830
+ self._session_id: str | None = None
831
+
832
+ def _send(self, payload: dict[str, object]) -> None:
833
+ headers = {
834
+ "Accept": "application/json",
835
+ "Content-Type": "application/json",
836
+ "MCP-Protocol-Version": PROTOCOL_VERSION,
837
+ **self._headers,
838
+ }
839
+ if self._session_id:
840
+ headers["MCP-Session-Id"] = self._session_id
841
+ response = self._client.post(
842
+ self.server.url,
843
+ headers=headers,
844
+ json=payload,
845
+ timeout=self.timeout_seconds,
846
+ )
847
+ session_id = response.headers.get("MCP-Session-Id") or response.headers.get(
848
+ "mcp-session-id"
849
+ )
850
+ if session_id:
851
+ self._session_id = session_id
852
+ response.raise_for_status()
853
+ content_type = (response.headers.get("Content-Type") or "").lower()
854
+ response_text = response.text if getattr(response, "text", None) else ""
855
+ stripped_text = response_text.lstrip()
856
+ if (
857
+ "text/html" in content_type
858
+ or stripped_text.startswith("<!doctype html")
859
+ or stripped_text.startswith("<html")
860
+ ):
861
+ raise MCPError(
862
+ "MCP request was blocked by an HTML challenge or interstitial response"
863
+ )
864
+ if "text/event-stream" in content_type:
865
+ self._pending_response = _parse_sse_payload(response_text)
866
+ return
867
+ raw_response = response.json() if response.content else {}
868
+ if isinstance(raw_response, list):
869
+ self._pending_response = [
870
+ item for item in raw_response if isinstance(item, dict)
871
+ ]
872
+ return
873
+ if isinstance(raw_response, dict):
874
+ self._pending_response = raw_response
875
+ return
876
+ self._pending_response = {}
877
+
878
+ def _receive(self) -> dict[str, Any]:
879
+ payload = self._pending_response
880
+ if isinstance(payload, list):
881
+ for item in payload:
882
+ if isinstance(item, dict):
883
+ return item
884
+ raise MCPError("Invalid MCP HTTP response")
885
+ if not isinstance(payload, dict):
886
+ raise MCPError("Invalid MCP HTTP response")
887
+ return payload
888
+
889
+ def close(self) -> None:
890
+ if self._session_id:
891
+ with suppress(Exception):
892
+ self._client.delete(
893
+ self.server.url,
894
+ headers={"MCP-Session-Id": self._session_id},
895
+ timeout=self.timeout_seconds,
896
+ )
897
+ self._client.close()
898
+
899
+
900
+ def _list_paginated(
901
+ connection: _BaseConnection,
902
+ *,
903
+ method: str,
904
+ result_key: str,
905
+ ) -> list[dict[str, Any]]:
906
+ items: list[dict[str, Any]] = []
907
+ cursor: str | None = None
908
+ while True:
909
+ params = {"cursor": cursor} if cursor else None
910
+ result = connection.request(method, params)
911
+ chunk = result.get(result_key)
912
+ if isinstance(chunk, list):
913
+ items.extend(item for item in chunk if isinstance(item, dict))
914
+ next_cursor = result.get("nextCursor")
915
+ if not isinstance(next_cursor, str) or not next_cursor.strip():
916
+ break
917
+ cursor = next_cursor
918
+ return items
919
+
920
+
921
+ def _parse_sse_payload(payload: str) -> list[dict[str, Any]]:
922
+ messages: list[dict[str, Any]] = []
923
+ data_lines: list[str] = []
924
+ for line in payload.splitlines():
925
+ if line.startswith("data:"):
926
+ data_lines.append(line.removeprefix("data:").strip())
927
+ continue
928
+ if line.strip():
929
+ continue
930
+ if not data_lines:
931
+ continue
932
+ try:
933
+ message = json.loads("\n".join(data_lines))
934
+ except json.JSONDecodeError:
935
+ data_lines = []
936
+ continue
937
+ if isinstance(message, dict):
938
+ messages.append(message)
939
+ data_lines = []
940
+ if data_lines:
941
+ try:
942
+ message = json.loads("\n".join(data_lines))
943
+ except json.JSONDecodeError:
944
+ return messages
945
+ if isinstance(message, dict):
946
+ messages.append(message)
947
+ return messages
948
+
949
+
950
+ def _auth_status_for_server(
951
+ server: MCPServerConfig,
952
+ *,
953
+ force_logged_out: bool = False,
954
+ ) -> tuple[str, str | None]:
955
+ if server.transport == "stdio":
956
+ return "unsupported", None
957
+ if force_logged_out:
958
+ return ("not_logged_in", "MCP server session is logged out")
959
+ headers, bearer_token = _build_http_headers(server)
960
+ _ = headers
961
+ auth_expected = bool(
962
+ server.bearer_token_env_var or server.oauth_resource or server.scopes
963
+ )
964
+ if auth_expected and not bearer_token:
965
+ if server.bearer_token_env_var:
966
+ return (
967
+ "not_logged_in",
968
+ f"Set env var {server.bearer_token_env_var} before refreshing this server",
969
+ )
970
+ return (
971
+ "not_logged_in",
972
+ "Authentication is required before refreshing this server",
973
+ )
974
+ return ("connected" if auth_expected else "unsupported", None)
975
+
976
+
977
+ class MCPService:
978
+ def __init__(self) -> None:
979
+ self._lock = threading.Lock()
980
+ self._snapshots: dict[str, MCPDiscoverySnapshot] = {}
981
+ self._logged_out_servers: set[str] = set()
982
+
983
+ def reset(self) -> None:
984
+ with self._lock:
985
+ self._snapshots.clear()
986
+ self._logged_out_servers.clear()
987
+ connection = open_state_db(create=False)
988
+ if connection is None:
989
+ return
990
+ try:
991
+ with connection:
992
+ connection.execute("DELETE FROM mcp_snapshots")
993
+ connection.execute("DELETE FROM mcp_activities")
994
+ finally:
995
+ connection.close()
996
+
997
+ def clear_runtime_state(self) -> None:
998
+ with self._lock:
999
+ self._snapshots.clear()
1000
+ self._logged_out_servers.clear()
1001
+
1002
+ def bootstrap(self) -> None:
1003
+ settings = get_settings()
1004
+ for server in settings.mcp_servers:
1005
+ if not server.enabled:
1006
+ self._set_snapshot(
1007
+ MCPDiscoverySnapshot(
1008
+ server_name=server.name,
1009
+ transport=server.transport,
1010
+ status="disabled",
1011
+ auth_status="unsupported"
1012
+ if server.transport == "stdio"
1013
+ else "not_logged_in",
1014
+ )
1015
+ )
1016
+ continue
1017
+ try:
1018
+ self.refresh_server(server.name)
1019
+ except MCPError as exc:
1020
+ if server.required:
1021
+ raise RuntimeError(str(exc)) from exc
1022
+
1023
+ def _prune_activities_locked(self, connection, now: float) -> None:
1024
+ min_timestamp = now - ACTIVITY_RETENTION_SECONDS
1025
+ connection.execute(
1026
+ "DELETE FROM mcp_activities WHERE ended_at < ?",
1027
+ (min_timestamp,),
1028
+ )
1029
+
1030
+ def _record_activity(
1031
+ self,
1032
+ *,
1033
+ server_name: str,
1034
+ action: str,
1035
+ actor_node_id: str | None,
1036
+ tab_id: str | None,
1037
+ started_at: float,
1038
+ ended_at: float,
1039
+ result: str,
1040
+ summary: str,
1041
+ tool_name: str | None = None,
1042
+ fully_qualified_id: str | None = None,
1043
+ target: str | None = None,
1044
+ approval_result: str | None = None,
1045
+ ) -> None:
1046
+ record = MCPActivityRecord(
1047
+ id=str(uuid.uuid4()),
1048
+ server_name=server_name,
1049
+ action=action,
1050
+ actor_node_id=actor_node_id,
1051
+ tab_id=tab_id,
1052
+ started_at=started_at,
1053
+ ended_at=ended_at,
1054
+ result=result,
1055
+ summary=summary,
1056
+ tool_name=tool_name,
1057
+ fully_qualified_id=fully_qualified_id,
1058
+ target=target,
1059
+ approval_result=approval_result,
1060
+ )
1061
+ with self._lock:
1062
+ connection = open_state_db(create=True)
1063
+ assert connection is not None
1064
+ try:
1065
+ with connection:
1066
+ self._prune_activities_locked(connection, ended_at)
1067
+ connection.execute(
1068
+ """
1069
+ INSERT INTO mcp_activities (
1070
+ id,
1071
+ server_name,
1072
+ ended_at,
1073
+ payload
1074
+ ) VALUES (?, ?, ?, ?)
1075
+ """,
1076
+ (
1077
+ record.id,
1078
+ record.server_name,
1079
+ record.ended_at,
1080
+ json.dumps(record.serialize(), ensure_ascii=False),
1081
+ ),
1082
+ )
1083
+ finally:
1084
+ connection.close()
1085
+
1086
+ def _persist_snapshot_locked(self, snapshot: MCPDiscoverySnapshot) -> None:
1087
+ connection = open_state_db(create=True)
1088
+ assert connection is not None
1089
+ try:
1090
+ with connection:
1091
+ connection.execute(
1092
+ """
1093
+ INSERT OR REPLACE INTO mcp_snapshots (server_name, payload)
1094
+ VALUES (?, ?)
1095
+ """,
1096
+ (
1097
+ snapshot.server_name,
1098
+ json.dumps(snapshot.serialize(), ensure_ascii=False),
1099
+ ),
1100
+ )
1101
+ finally:
1102
+ connection.close()
1103
+
1104
+ def _delete_snapshot_locked(self, server_name: str) -> None:
1105
+ connection = open_state_db(create=False)
1106
+ if connection is None:
1107
+ return
1108
+ try:
1109
+ with connection:
1110
+ connection.execute(
1111
+ "DELETE FROM mcp_snapshots WHERE server_name = ?",
1112
+ (server_name,),
1113
+ )
1114
+ finally:
1115
+ connection.close()
1116
+
1117
+ def _load_snapshot_from_db_locked(
1118
+ self,
1119
+ server_name: str,
1120
+ ) -> MCPDiscoverySnapshot | None:
1121
+ connection = open_state_db(create=False)
1122
+ if connection is None:
1123
+ return None
1124
+ try:
1125
+ row = connection.execute(
1126
+ "SELECT payload FROM mcp_snapshots WHERE server_name = ?",
1127
+ (server_name,),
1128
+ ).fetchone()
1129
+ finally:
1130
+ connection.close()
1131
+ if row is None:
1132
+ return None
1133
+ payload = row["payload"]
1134
+ if not isinstance(payload, str):
1135
+ return None
1136
+ parsed = json.loads(payload)
1137
+ if not isinstance(parsed, dict):
1138
+ return None
1139
+ return _snapshot_from_mapping(parsed)
1140
+
1141
+ def _set_snapshot(self, snapshot: MCPDiscoverySnapshot) -> None:
1142
+ with self._lock:
1143
+ self._snapshots[snapshot.server_name] = snapshot
1144
+ self._persist_snapshot_locked(snapshot)
1145
+
1146
+ def _get_snapshot(self, server_name: str) -> MCPDiscoverySnapshot | None:
1147
+ with self._lock:
1148
+ snapshot = self._snapshots.get(server_name)
1149
+ if snapshot is not None:
1150
+ return snapshot
1151
+ snapshot = self._load_snapshot_from_db_locked(server_name)
1152
+ if snapshot is not None:
1153
+ self._snapshots[server_name] = snapshot
1154
+ return snapshot
1155
+
1156
+ def _build_connection(
1157
+ self,
1158
+ server: MCPServerConfig,
1159
+ *,
1160
+ timeout_seconds: int,
1161
+ roots: list[dict[str, str]] | None = None,
1162
+ ) -> _BaseConnection:
1163
+ if server.transport == "stdio":
1164
+ if not server.command.strip():
1165
+ raise MCPError(f"MCP server '{server.name}' is missing a command")
1166
+ return _StdioConnection(
1167
+ server,
1168
+ timeout_seconds=timeout_seconds,
1169
+ roots=roots or [],
1170
+ )
1171
+ if not server.url.strip():
1172
+ raise MCPError(f"MCP server '{server.name}' is missing a URL")
1173
+ return _HttpConnection(server, timeout_seconds=timeout_seconds)
1174
+
1175
+ def _discover_server(
1176
+ self,
1177
+ server: MCPServerConfig,
1178
+ *,
1179
+ auth_result: str | None = None,
1180
+ ) -> MCPDiscoverySnapshot:
1181
+ auth_status, auth_error = _auth_status_for_server(
1182
+ server,
1183
+ force_logged_out=server.name in self._logged_out_servers,
1184
+ )
1185
+ if server.transport == "streamable_http" and auth_status == "not_logged_in":
1186
+ return MCPDiscoverySnapshot(
1187
+ server_name=server.name,
1188
+ transport=server.transport,
1189
+ status="auth_required",
1190
+ auth_status=auth_status,
1191
+ last_auth_result=auth_result,
1192
+ last_refresh_at=time.time(),
1193
+ last_refresh_result="error",
1194
+ last_error=auth_error,
1195
+ )
1196
+
1197
+ connection = self._build_connection(
1198
+ server,
1199
+ timeout_seconds=server.startup_timeout_sec,
1200
+ )
1201
+ try:
1202
+ connection.initialize()
1203
+ tools = [
1204
+ descriptor
1205
+ for descriptor in (
1206
+ _parse_tool_descriptor(server.name, raw_tool)
1207
+ for raw_tool in _list_paginated(
1208
+ connection,
1209
+ method="tools/list",
1210
+ result_key="tools",
1211
+ )
1212
+ )
1213
+ if descriptor is not None
1214
+ ]
1215
+ if server.enabled_tools:
1216
+ tools = [
1217
+ descriptor
1218
+ for descriptor in tools
1219
+ if descriptor.tool_name in server.enabled_tools
1220
+ ]
1221
+ if server.disabled_tools:
1222
+ disabled_tool_names = set(server.disabled_tools)
1223
+ tools = [
1224
+ descriptor
1225
+ for descriptor in tools
1226
+ if descriptor.tool_name not in disabled_tool_names
1227
+ ]
1228
+ resources = [
1229
+ descriptor
1230
+ for descriptor in (
1231
+ _parse_resource_descriptor(server.name, raw_resource)
1232
+ for raw_resource in _list_paginated(
1233
+ connection,
1234
+ method="resources/list",
1235
+ result_key="resources",
1236
+ )
1237
+ )
1238
+ if descriptor is not None
1239
+ ]
1240
+ resource_templates = [
1241
+ descriptor
1242
+ for descriptor in (
1243
+ _parse_resource_template_descriptor(server.name, raw_template)
1244
+ for raw_template in _list_paginated(
1245
+ connection,
1246
+ method="resources/templates/list",
1247
+ result_key="resourceTemplates",
1248
+ )
1249
+ )
1250
+ if descriptor is not None
1251
+ ]
1252
+ prompts = [
1253
+ descriptor
1254
+ for descriptor in (
1255
+ _parse_prompt_descriptor(server.name, raw_prompt)
1256
+ for raw_prompt in _list_paginated(
1257
+ connection,
1258
+ method="prompts/list",
1259
+ result_key="prompts",
1260
+ )
1261
+ )
1262
+ if descriptor is not None
1263
+ ]
1264
+ return MCPDiscoverySnapshot(
1265
+ server_name=server.name,
1266
+ transport=server.transport,
1267
+ status="connected",
1268
+ auth_status=auth_status,
1269
+ last_auth_result=auth_result,
1270
+ last_refresh_at=time.time(),
1271
+ last_refresh_result="success",
1272
+ tools=tools,
1273
+ resources=resources,
1274
+ resource_templates=resource_templates,
1275
+ prompts=prompts,
1276
+ )
1277
+ finally:
1278
+ connection.close()
1279
+
1280
+ def list_server_payloads(self) -> list[dict[str, object]]:
1281
+ settings = get_settings()
1282
+ payloads: list[dict[str, object]] = []
1283
+ for server in settings.mcp_servers:
1284
+ snapshot = self._get_snapshot(server.name)
1285
+ server_payload: dict[str, object] = {
1286
+ "config": {
1287
+ "name": server.name,
1288
+ "transport": server.transport,
1289
+ "enabled": server.enabled,
1290
+ "required": server.required,
1291
+ "startup_timeout_sec": server.startup_timeout_sec,
1292
+ "tool_timeout_sec": server.tool_timeout_sec,
1293
+ "enabled_tools": list(server.enabled_tools),
1294
+ "disabled_tools": list(server.disabled_tools),
1295
+ "scopes": list(server.scopes),
1296
+ "oauth_resource": server.oauth_resource,
1297
+ "launcher": server.launcher,
1298
+ "command": server.command,
1299
+ "args": list(server.args),
1300
+ "env": dict(server.env),
1301
+ "env_vars": list(server.env_vars),
1302
+ "cwd": server.cwd,
1303
+ "url": server.url,
1304
+ "bearer_token_env_var": server.bearer_token_env_var,
1305
+ "http_headers": dict(server.http_headers),
1306
+ "env_http_headers": list(server.env_http_headers),
1307
+ },
1308
+ "snapshot": snapshot.serialize()
1309
+ if snapshot is not None
1310
+ else MCPDiscoverySnapshot(
1311
+ server_name=server.name,
1312
+ transport=server.transport,
1313
+ status="disabled" if not server.enabled else "connecting",
1314
+ auth_status="unsupported"
1315
+ if server.transport == "stdio"
1316
+ else "not_logged_in",
1317
+ ).serialize(),
1318
+ "visibility": {
1319
+ "scope": "global",
1320
+ "active": snapshot is not None and snapshot.status == "connected",
1321
+ },
1322
+ "activity": [
1323
+ activity.serialize()
1324
+ for activity in self.list_activities(server_name=server.name)
1325
+ ],
1326
+ }
1327
+ payloads.append(server_payload)
1328
+ return payloads
1329
+
1330
+ def list_activities(
1331
+ self, *, server_name: str | None = None
1332
+ ) -> list[MCPActivityRecord]:
1333
+ with self._lock:
1334
+ connection = open_state_db(create=False)
1335
+ if connection is None:
1336
+ return []
1337
+ try:
1338
+ with connection:
1339
+ self._prune_activities_locked(connection, time.time())
1340
+ if server_name is None:
1341
+ rows = connection.execute(
1342
+ """
1343
+ SELECT payload
1344
+ FROM mcp_activities
1345
+ ORDER BY ended_at DESC
1346
+ """
1347
+ ).fetchall()
1348
+ else:
1349
+ rows = connection.execute(
1350
+ """
1351
+ SELECT payload
1352
+ FROM mcp_activities
1353
+ WHERE server_name = ?
1354
+ ORDER BY ended_at DESC
1355
+ """,
1356
+ (server_name,),
1357
+ ).fetchall()
1358
+ finally:
1359
+ connection.close()
1360
+ records: list[MCPActivityRecord] = []
1361
+ for row in rows:
1362
+ payload = row["payload"]
1363
+ if not isinstance(payload, str):
1364
+ continue
1365
+ parsed = json.loads(payload)
1366
+ if not isinstance(parsed, dict):
1367
+ continue
1368
+ record = _activity_from_mapping(parsed)
1369
+ if record is not None:
1370
+ records.append(record)
1371
+ return records
1372
+
1373
+ def refresh_server(self, server_name: str) -> dict[str, object]:
1374
+ settings = get_settings()
1375
+ server = find_mcp_server(settings, server_name)
1376
+ if server is None:
1377
+ raise MCPError(f"MCP server '{server_name}' not found")
1378
+ if not server.enabled:
1379
+ snapshot = MCPDiscoverySnapshot(
1380
+ server_name=server.name,
1381
+ transport=server.transport,
1382
+ status="disabled",
1383
+ auth_status="unsupported"
1384
+ if server.transport == "stdio"
1385
+ else "not_logged_in",
1386
+ last_auth_result=None,
1387
+ last_refresh_at=time.time(),
1388
+ last_refresh_result="success",
1389
+ )
1390
+ self._set_snapshot(snapshot)
1391
+ return snapshot.serialize()
1392
+ started_at = time.time()
1393
+ try:
1394
+ previous_snapshot = self._get_snapshot(server.name)
1395
+ snapshot = self._discover_server(server)
1396
+ if snapshot.last_auth_result is None and previous_snapshot is not None:
1397
+ snapshot.last_auth_result = previous_snapshot.last_auth_result
1398
+ self._set_snapshot(snapshot)
1399
+ self._record_activity(
1400
+ server_name=server.name,
1401
+ action="refresh",
1402
+ actor_node_id=None,
1403
+ tab_id=None,
1404
+ started_at=started_at,
1405
+ ended_at=time.time(),
1406
+ result="success"
1407
+ if snapshot.last_refresh_result == "success"
1408
+ else "error",
1409
+ summary=(
1410
+ "Capabilities refreshed"
1411
+ if snapshot.last_refresh_result == "success"
1412
+ else snapshot.last_error or "Failed to refresh capabilities"
1413
+ ),
1414
+ )
1415
+ return snapshot.serialize()
1416
+ except Exception as exc:
1417
+ snapshot = MCPDiscoverySnapshot(
1418
+ server_name=server.name,
1419
+ transport=server.transport,
1420
+ status="error",
1421
+ auth_status="error"
1422
+ if server.transport == "streamable_http"
1423
+ else "unsupported",
1424
+ last_auth_result=None,
1425
+ last_refresh_at=time.time(),
1426
+ last_refresh_result="error",
1427
+ last_error=str(exc),
1428
+ )
1429
+ self._set_snapshot(snapshot)
1430
+ self._record_activity(
1431
+ server_name=server.name,
1432
+ action="refresh",
1433
+ actor_node_id=None,
1434
+ tab_id=None,
1435
+ started_at=started_at,
1436
+ ended_at=time.time(),
1437
+ result="error",
1438
+ summary=str(exc),
1439
+ )
1440
+ raise MCPError(str(exc)) from exc
1441
+
1442
+ def refresh_all(self) -> list[dict[str, object]]:
1443
+ results: list[dict[str, object]] = []
1444
+ for server in get_settings().mcp_servers:
1445
+ try:
1446
+ results.append(self.refresh_server(server.name))
1447
+ except MCPError:
1448
+ snapshot = self._get_snapshot(server.name)
1449
+ if snapshot is not None:
1450
+ results.append(snapshot.serialize())
1451
+ return results
1452
+
1453
+ def create_or_update_server(
1454
+ self,
1455
+ *,
1456
+ current_name: str | None,
1457
+ config_data: dict[str, object],
1458
+ ) -> dict[str, object]:
1459
+ settings = get_settings()
1460
+ normalized_name = config_data.get("name")
1461
+ if not isinstance(normalized_name, str) or not normalized_name.strip():
1462
+ raise MCPError("name must not be empty")
1463
+ next_name = normalized_name.strip()
1464
+ existing = find_mcp_server(settings, next_name)
1465
+ if existing is not None and existing.name != current_name:
1466
+ raise MCPError(f"MCP server '{next_name}' already exists")
1467
+ from flowent.settings import _build_mcp_server_config
1468
+
1469
+ server_config, migrated = _build_mcp_server_config(config_data)
1470
+ _ = migrated
1471
+ if server_config is None:
1472
+ raise MCPError("Invalid MCP server config")
1473
+ if current_name is not None and current_name != next_name:
1474
+ with self._lock:
1475
+ self._snapshots.pop(current_name, None)
1476
+ self._logged_out_servers.discard(current_name)
1477
+ self._delete_snapshot_locked(current_name)
1478
+ replaced = False
1479
+ for index, existing_server in enumerate(settings.mcp_servers):
1480
+ if existing_server.name != (current_name or next_name):
1481
+ continue
1482
+ settings.mcp_servers[index] = server_config
1483
+ replaced = True
1484
+ break
1485
+ if not replaced:
1486
+ settings.mcp_servers.append(server_config)
1487
+ save_settings(settings)
1488
+ if not server_config.enabled:
1489
+ self._logged_out_servers.discard(server_config.name)
1490
+ snapshot = MCPDiscoverySnapshot(
1491
+ server_name=server_config.name,
1492
+ transport=server_config.transport,
1493
+ status="disabled",
1494
+ auth_status="unsupported"
1495
+ if server_config.transport == "stdio"
1496
+ else "not_logged_in",
1497
+ last_auth_result=None,
1498
+ last_refresh_at=time.time(),
1499
+ last_refresh_result="success",
1500
+ )
1501
+ self._set_snapshot(snapshot)
1502
+ return snapshot.serialize()
1503
+ self._logged_out_servers.discard(server_config.name)
1504
+ return self.refresh_server(server_config.name)
1505
+
1506
+ def delete_server(self, server_name: str) -> None:
1507
+ settings = get_settings()
1508
+ if find_mcp_server(settings, server_name) is None:
1509
+ raise MCPError(f"MCP server '{server_name}' not found")
1510
+ settings.mcp_servers = [
1511
+ server for server in settings.mcp_servers if server.name != server_name
1512
+ ]
1513
+ save_settings(settings)
1514
+ with self._lock:
1515
+ self._snapshots.pop(server_name, None)
1516
+ self._logged_out_servers.discard(server_name)
1517
+ self._delete_snapshot_locked(server_name)
1518
+
1519
+ def login_server(self, server_name: str) -> dict[str, object]:
1520
+ self._logged_out_servers.discard(server_name)
1521
+ started_at = time.time()
1522
+ snapshot = self.refresh_server(server_name)
1523
+ current_snapshot = self._get_snapshot(server_name)
1524
+ if current_snapshot is not None:
1525
+ current_snapshot.last_auth_result = (
1526
+ "success" if current_snapshot.auth_status == "connected" else "error"
1527
+ )
1528
+ self._set_snapshot(current_snapshot)
1529
+ self._record_activity(
1530
+ server_name=server_name,
1531
+ action="login",
1532
+ actor_node_id=None,
1533
+ tab_id=None,
1534
+ started_at=started_at,
1535
+ ended_at=time.time(),
1536
+ result="success"
1537
+ if current_snapshot.auth_status == "connected"
1538
+ else "error",
1539
+ summary=(
1540
+ "Logged in MCP server session"
1541
+ if current_snapshot.auth_status == "connected"
1542
+ else current_snapshot.last_error or "Failed to login MCP server"
1543
+ ),
1544
+ )
1545
+ return current_snapshot.serialize()
1546
+ return snapshot
1547
+
1548
+ def logout_server(self, server_name: str) -> dict[str, object]:
1549
+ settings = get_settings()
1550
+ server = find_mcp_server(settings, server_name)
1551
+ if server is None:
1552
+ raise MCPError(f"MCP server '{server_name}' not found")
1553
+ if server.transport == "stdio":
1554
+ raise MCPError("Logout is not available for stdio MCP servers")
1555
+ self._logged_out_servers.add(server_name)
1556
+ snapshot = MCPDiscoverySnapshot(
1557
+ server_name=server.name,
1558
+ transport=server.transport,
1559
+ status="auth_required",
1560
+ auth_status="not_logged_in",
1561
+ last_auth_result="logged_out",
1562
+ last_refresh_at=time.time(),
1563
+ last_refresh_result="success",
1564
+ last_error=None,
1565
+ )
1566
+ self._set_snapshot(snapshot)
1567
+ self._record_activity(
1568
+ server_name=server_name,
1569
+ action="logout",
1570
+ actor_node_id=None,
1571
+ tab_id=None,
1572
+ started_at=time.time(),
1573
+ ended_at=time.time(),
1574
+ result="success",
1575
+ summary="Logged out MCP server session",
1576
+ )
1577
+ return snapshot.serialize()
1578
+
1579
+ def _visible_server_names_for_agent(self, agent: Agent) -> list[str]:
1580
+ settings = get_settings()
1581
+ visible_names: list[str] = []
1582
+ seen: set[str] = set()
1583
+ for server in settings.mcp_servers:
1584
+ if server.name in seen or not server.enabled:
1585
+ continue
1586
+ snapshot = self._get_snapshot(server.name)
1587
+ if snapshot is None or snapshot.status != "connected":
1588
+ continue
1589
+ seen.add(server.name)
1590
+ visible_names.append(server.name)
1591
+ return visible_names
1592
+
1593
+ def _visible_snapshots_for_agent(self, agent: Agent) -> list[MCPDiscoverySnapshot]:
1594
+ snapshots: list[MCPDiscoverySnapshot] = []
1595
+ for server_name in self._visible_server_names_for_agent(agent):
1596
+ snapshot = self._get_snapshot(server_name)
1597
+ if snapshot is None or snapshot.status != "connected":
1598
+ continue
1599
+ snapshots.append(snapshot)
1600
+ return snapshots
1601
+
1602
+ def list_discovered_tool_descriptors(self) -> list[dict[str, object]]:
1603
+ return [descriptor.serialize() for descriptor in self.list_discovered_tools()]
1604
+
1605
+ def list_discovered_tools(self) -> list[MCPToolDescriptor]:
1606
+ tools: list[MCPToolDescriptor] = []
1607
+ with self._lock:
1608
+ snapshots = list(self._snapshots.values())
1609
+ for snapshot in snapshots:
1610
+ if snapshot.status != "connected":
1611
+ continue
1612
+ tools.extend(snapshot.tools)
1613
+ return tools
1614
+
1615
+ def get_dynamic_tool_descriptor(
1616
+ self,
1617
+ fully_qualified_id: str,
1618
+ ) -> MCPToolDescriptor | None:
1619
+ for descriptor in self.list_discovered_tools():
1620
+ if descriptor.fully_qualified_id == fully_qualified_id:
1621
+ return descriptor
1622
+ return None
1623
+
1624
+ def list_agent_dynamic_tools(self, agent: Agent) -> list[MCPToolDescriptor]:
1625
+ tools: list[MCPToolDescriptor] = []
1626
+ for snapshot in self._visible_snapshots_for_agent(agent):
1627
+ tools.extend(snapshot.tools)
1628
+ return tools
1629
+
1630
+ def has_visible_capabilities(self, agent: Agent) -> bool:
1631
+ return bool(self._visible_snapshots_for_agent(agent))
1632
+
1633
+ def list_agent_resources(
1634
+ self,
1635
+ agent: Agent,
1636
+ *,
1637
+ server_name: str | None = None,
1638
+ ) -> list[dict[str, object]]:
1639
+ resources: list[dict[str, object]] = []
1640
+ for snapshot in self._visible_snapshots_for_agent(agent):
1641
+ if server_name is not None and snapshot.server_name != server_name:
1642
+ continue
1643
+ resources.extend(item.serialize() for item in snapshot.resources)
1644
+ return resources
1645
+
1646
+ def list_agent_resource_templates(
1647
+ self,
1648
+ agent: Agent,
1649
+ *,
1650
+ server_name: str | None = None,
1651
+ ) -> list[dict[str, object]]:
1652
+ templates: list[dict[str, object]] = []
1653
+ for snapshot in self._visible_snapshots_for_agent(agent):
1654
+ if server_name is not None and snapshot.server_name != server_name:
1655
+ continue
1656
+ templates.extend(item.serialize() for item in snapshot.resource_templates)
1657
+ return templates
1658
+
1659
+ def list_agent_prompts(
1660
+ self,
1661
+ agent: Agent,
1662
+ *,
1663
+ server_name: str | None = None,
1664
+ ) -> list[dict[str, object]]:
1665
+ prompts: list[dict[str, object]] = []
1666
+ for snapshot in self._visible_snapshots_for_agent(agent):
1667
+ if server_name is not None and snapshot.server_name != server_name:
1668
+ continue
1669
+ prompts.extend(item.serialize() for item in snapshot.prompts)
1670
+ return prompts
1671
+
1672
+ def _get_server_for_agent(self, agent: Agent, server_name: str) -> MCPServerConfig:
1673
+ if server_name not in self._visible_server_names_for_agent(agent):
1674
+ raise MCPError(f"MCP server '{server_name}' is not globally available")
1675
+ server = find_mcp_server(get_settings(), server_name)
1676
+ if server is None or not server.enabled:
1677
+ raise MCPError(f"MCP server '{server_name}' is unavailable")
1678
+ if server.transport == "streamable_http" and not agent.config.allow_network:
1679
+ raise MCPError("Network access is disabled for this node")
1680
+ return server
1681
+
1682
+ def read_agent_resource(
1683
+ self,
1684
+ agent: Agent,
1685
+ *,
1686
+ server_name: str,
1687
+ uri: str,
1688
+ ) -> dict[str, Any]:
1689
+ server = self._get_server_for_agent(agent, server_name)
1690
+ started_at = time.time()
1691
+ connection = self._build_connection(
1692
+ server,
1693
+ timeout_seconds=server.tool_timeout_sec,
1694
+ roots=_build_roots_for_agent(agent),
1695
+ )
1696
+ try:
1697
+ connection.initialize()
1698
+ result = connection.request("resources/read", {"uri": uri})
1699
+ self._record_activity(
1700
+ server_name=server_name,
1701
+ action="resource_read",
1702
+ actor_node_id=agent.uuid,
1703
+ tab_id=agent.config.tab_id,
1704
+ started_at=started_at,
1705
+ ended_at=time.time(),
1706
+ result="success",
1707
+ summary=f"Read resource {uri}",
1708
+ target=uri,
1709
+ )
1710
+ return result
1711
+ except Exception as exc:
1712
+ self._record_activity(
1713
+ server_name=server_name,
1714
+ action="resource_read",
1715
+ actor_node_id=agent.uuid,
1716
+ tab_id=agent.config.tab_id,
1717
+ started_at=started_at,
1718
+ ended_at=time.time(),
1719
+ result="error",
1720
+ summary=str(exc),
1721
+ target=uri,
1722
+ )
1723
+ raise MCPError(str(exc)) from exc
1724
+ finally:
1725
+ connection.close()
1726
+
1727
+ def get_agent_prompt(
1728
+ self,
1729
+ agent: Agent,
1730
+ *,
1731
+ server_name: str,
1732
+ name: str,
1733
+ arguments: dict[str, Any] | None = None,
1734
+ ) -> dict[str, Any]:
1735
+ server = self._get_server_for_agent(agent, server_name)
1736
+ started_at = time.time()
1737
+ connection = self._build_connection(
1738
+ server,
1739
+ timeout_seconds=server.tool_timeout_sec,
1740
+ roots=_build_roots_for_agent(agent),
1741
+ )
1742
+ try:
1743
+ connection.initialize()
1744
+ params: dict[str, Any] = {"name": name}
1745
+ if arguments:
1746
+ params["arguments"] = arguments
1747
+ result = connection.request("prompts/get", params)
1748
+ self._record_activity(
1749
+ server_name=server_name,
1750
+ action="prompt_get",
1751
+ actor_node_id=agent.uuid,
1752
+ tab_id=agent.config.tab_id,
1753
+ started_at=started_at,
1754
+ ended_at=time.time(),
1755
+ result="success",
1756
+ summary=f"Loaded prompt {name}",
1757
+ target=name,
1758
+ )
1759
+ return result
1760
+ except Exception as exc:
1761
+ self._record_activity(
1762
+ server_name=server_name,
1763
+ action="prompt_get",
1764
+ actor_node_id=agent.uuid,
1765
+ tab_id=agent.config.tab_id,
1766
+ started_at=started_at,
1767
+ ended_at=time.time(),
1768
+ result="error",
1769
+ summary=str(exc),
1770
+ target=name,
1771
+ )
1772
+ raise MCPError(str(exc)) from exc
1773
+ finally:
1774
+ connection.close()
1775
+
1776
+ def preview_server_prompt(
1777
+ self,
1778
+ *,
1779
+ server_name: str,
1780
+ name: str,
1781
+ arguments: dict[str, Any] | None = None,
1782
+ ) -> dict[str, Any]:
1783
+ settings = get_settings()
1784
+ server = find_mcp_server(settings, server_name)
1785
+ if server is None or not server.enabled:
1786
+ raise MCPError(f"MCP server '{server_name}' is unavailable")
1787
+ started_at = time.time()
1788
+ connection = self._build_connection(
1789
+ server,
1790
+ timeout_seconds=server.tool_timeout_sec,
1791
+ )
1792
+ try:
1793
+ connection.initialize()
1794
+ params: dict[str, Any] = {"name": name}
1795
+ if arguments:
1796
+ params["arguments"] = arguments
1797
+ result = connection.request("prompts/get", params)
1798
+ self._record_activity(
1799
+ server_name=server_name,
1800
+ action="prompt_get",
1801
+ actor_node_id=None,
1802
+ tab_id=None,
1803
+ started_at=started_at,
1804
+ ended_at=time.time(),
1805
+ result="success",
1806
+ summary=f"Previewed prompt {name}",
1807
+ target=name,
1808
+ )
1809
+ return result
1810
+ except Exception as exc:
1811
+ self._record_activity(
1812
+ server_name=server_name,
1813
+ action="prompt_get",
1814
+ actor_node_id=None,
1815
+ tab_id=None,
1816
+ started_at=started_at,
1817
+ ended_at=time.time(),
1818
+ result="error",
1819
+ summary=str(exc),
1820
+ target=name,
1821
+ )
1822
+ raise MCPError(str(exc)) from exc
1823
+ finally:
1824
+ connection.close()
1825
+
1826
+ def call_agent_tool(
1827
+ self,
1828
+ agent: Agent,
1829
+ *,
1830
+ fully_qualified_id: str,
1831
+ arguments: dict[str, Any],
1832
+ ) -> dict[str, Any]:
1833
+ descriptor = next(
1834
+ (
1835
+ item
1836
+ for item in self.list_agent_dynamic_tools(agent)
1837
+ if item.fully_qualified_id == fully_qualified_id
1838
+ ),
1839
+ None,
1840
+ )
1841
+ if descriptor is None:
1842
+ raise MCPError(f"MCP tool '{fully_qualified_id}' is not available")
1843
+ if descriptor.destructive_hint or descriptor.open_world_hint:
1844
+ self._record_activity(
1845
+ server_name=descriptor.server_name,
1846
+ action="tool_call",
1847
+ actor_node_id=agent.uuid,
1848
+ tab_id=agent.config.tab_id,
1849
+ started_at=time.time(),
1850
+ ended_at=time.time(),
1851
+ result="rejected",
1852
+ summary="Explicit MCP approval is required for this tool",
1853
+ tool_name=descriptor.tool_name,
1854
+ fully_qualified_id=descriptor.fully_qualified_id,
1855
+ approval_result="requires_approval",
1856
+ )
1857
+ raise MCPError("Explicit MCP approval is required for this tool")
1858
+ server = self._get_server_for_agent(agent, descriptor.server_name)
1859
+ started_at = time.time()
1860
+ connection = self._build_connection(
1861
+ server,
1862
+ timeout_seconds=server.tool_timeout_sec,
1863
+ roots=_build_roots_for_agent(agent),
1864
+ )
1865
+ try:
1866
+ connection.initialize()
1867
+ result = connection.request(
1868
+ "tools/call",
1869
+ {"name": descriptor.tool_name, "arguments": arguments},
1870
+ )
1871
+ self._record_activity(
1872
+ server_name=descriptor.server_name,
1873
+ action="tool_call",
1874
+ actor_node_id=agent.uuid,
1875
+ tab_id=agent.config.tab_id,
1876
+ started_at=started_at,
1877
+ ended_at=time.time(),
1878
+ result="success",
1879
+ summary=f"Called tool {descriptor.tool_name}",
1880
+ tool_name=descriptor.tool_name,
1881
+ fully_qualified_id=descriptor.fully_qualified_id,
1882
+ approval_result="granted",
1883
+ )
1884
+ return result
1885
+ except Exception as exc:
1886
+ self._record_activity(
1887
+ server_name=descriptor.server_name,
1888
+ action="tool_call",
1889
+ actor_node_id=agent.uuid,
1890
+ tab_id=agent.config.tab_id,
1891
+ started_at=started_at,
1892
+ ended_at=time.time(),
1893
+ result="error",
1894
+ summary=str(exc),
1895
+ tool_name=descriptor.tool_name,
1896
+ fully_qualified_id=descriptor.fully_qualified_id,
1897
+ approval_result="granted",
1898
+ )
1899
+ raise MCPError(str(exc)) from exc
1900
+ finally:
1901
+ connection.close()
1902
+
1903
+
1904
+ mcp_service = MCPService()