flowent 0.0.6 → 0.0.7

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