lfx-nightly 0.2.0.dev25__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lfx-nightly might be problematic. Click here for more details.

Files changed (769) hide show
  1. lfx/__init__.py +0 -0
  2. lfx/__main__.py +25 -0
  3. lfx/_assets/component_index.json +1 -0
  4. lfx/base/__init__.py +0 -0
  5. lfx/base/agents/__init__.py +0 -0
  6. lfx/base/agents/agent.py +375 -0
  7. lfx/base/agents/altk_base_agent.py +380 -0
  8. lfx/base/agents/altk_tool_wrappers.py +565 -0
  9. lfx/base/agents/callback.py +130 -0
  10. lfx/base/agents/context.py +109 -0
  11. lfx/base/agents/crewai/__init__.py +0 -0
  12. lfx/base/agents/crewai/crew.py +231 -0
  13. lfx/base/agents/crewai/tasks.py +12 -0
  14. lfx/base/agents/default_prompts.py +23 -0
  15. lfx/base/agents/errors.py +15 -0
  16. lfx/base/agents/events.py +430 -0
  17. lfx/base/agents/utils.py +237 -0
  18. lfx/base/astra_assistants/__init__.py +0 -0
  19. lfx/base/astra_assistants/util.py +171 -0
  20. lfx/base/chains/__init__.py +0 -0
  21. lfx/base/chains/model.py +19 -0
  22. lfx/base/composio/__init__.py +0 -0
  23. lfx/base/composio/composio_base.py +2584 -0
  24. lfx/base/compressors/__init__.py +0 -0
  25. lfx/base/compressors/model.py +60 -0
  26. lfx/base/constants.py +46 -0
  27. lfx/base/curl/__init__.py +0 -0
  28. lfx/base/curl/parse.py +188 -0
  29. lfx/base/data/__init__.py +5 -0
  30. lfx/base/data/base_file.py +810 -0
  31. lfx/base/data/docling_utils.py +338 -0
  32. lfx/base/data/storage_utils.py +192 -0
  33. lfx/base/data/utils.py +362 -0
  34. lfx/base/datastax/__init__.py +5 -0
  35. lfx/base/datastax/astradb_base.py +896 -0
  36. lfx/base/document_transformers/__init__.py +0 -0
  37. lfx/base/document_transformers/model.py +43 -0
  38. lfx/base/embeddings/__init__.py +0 -0
  39. lfx/base/embeddings/aiml_embeddings.py +62 -0
  40. lfx/base/embeddings/embeddings_class.py +113 -0
  41. lfx/base/embeddings/model.py +26 -0
  42. lfx/base/flow_processing/__init__.py +0 -0
  43. lfx/base/flow_processing/utils.py +86 -0
  44. lfx/base/huggingface/__init__.py +0 -0
  45. lfx/base/huggingface/model_bridge.py +133 -0
  46. lfx/base/io/__init__.py +0 -0
  47. lfx/base/io/chat.py +21 -0
  48. lfx/base/io/text.py +22 -0
  49. lfx/base/knowledge_bases/__init__.py +3 -0
  50. lfx/base/knowledge_bases/knowledge_base_utils.py +137 -0
  51. lfx/base/langchain_utilities/__init__.py +0 -0
  52. lfx/base/langchain_utilities/model.py +35 -0
  53. lfx/base/langchain_utilities/spider_constants.py +1 -0
  54. lfx/base/langwatch/__init__.py +0 -0
  55. lfx/base/langwatch/utils.py +18 -0
  56. lfx/base/mcp/__init__.py +0 -0
  57. lfx/base/mcp/constants.py +2 -0
  58. lfx/base/mcp/util.py +1659 -0
  59. lfx/base/memory/__init__.py +0 -0
  60. lfx/base/memory/memory.py +49 -0
  61. lfx/base/memory/model.py +38 -0
  62. lfx/base/models/__init__.py +3 -0
  63. lfx/base/models/aiml_constants.py +51 -0
  64. lfx/base/models/anthropic_constants.py +51 -0
  65. lfx/base/models/aws_constants.py +151 -0
  66. lfx/base/models/chat_result.py +76 -0
  67. lfx/base/models/cometapi_constants.py +54 -0
  68. lfx/base/models/google_generative_ai_constants.py +70 -0
  69. lfx/base/models/google_generative_ai_model.py +38 -0
  70. lfx/base/models/groq_constants.py +150 -0
  71. lfx/base/models/groq_model_discovery.py +265 -0
  72. lfx/base/models/model.py +375 -0
  73. lfx/base/models/model_input_constants.py +378 -0
  74. lfx/base/models/model_metadata.py +41 -0
  75. lfx/base/models/model_utils.py +108 -0
  76. lfx/base/models/novita_constants.py +35 -0
  77. lfx/base/models/ollama_constants.py +52 -0
  78. lfx/base/models/openai_constants.py +129 -0
  79. lfx/base/models/sambanova_constants.py +18 -0
  80. lfx/base/models/watsonx_constants.py +36 -0
  81. lfx/base/processing/__init__.py +0 -0
  82. lfx/base/prompts/__init__.py +0 -0
  83. lfx/base/prompts/api_utils.py +224 -0
  84. lfx/base/prompts/utils.py +61 -0
  85. lfx/base/textsplitters/__init__.py +0 -0
  86. lfx/base/textsplitters/model.py +28 -0
  87. lfx/base/tools/__init__.py +0 -0
  88. lfx/base/tools/base.py +26 -0
  89. lfx/base/tools/component_tool.py +325 -0
  90. lfx/base/tools/constants.py +49 -0
  91. lfx/base/tools/flow_tool.py +132 -0
  92. lfx/base/tools/run_flow.py +698 -0
  93. lfx/base/vectorstores/__init__.py +0 -0
  94. lfx/base/vectorstores/model.py +193 -0
  95. lfx/base/vectorstores/utils.py +22 -0
  96. lfx/base/vectorstores/vector_store_connection_decorator.py +52 -0
  97. lfx/cli/__init__.py +5 -0
  98. lfx/cli/commands.py +327 -0
  99. lfx/cli/common.py +650 -0
  100. lfx/cli/run.py +506 -0
  101. lfx/cli/script_loader.py +289 -0
  102. lfx/cli/serve_app.py +546 -0
  103. lfx/cli/validation.py +69 -0
  104. lfx/components/FAISS/__init__.py +34 -0
  105. lfx/components/FAISS/faiss.py +111 -0
  106. lfx/components/Notion/__init__.py +19 -0
  107. lfx/components/Notion/add_content_to_page.py +269 -0
  108. lfx/components/Notion/create_page.py +94 -0
  109. lfx/components/Notion/list_database_properties.py +68 -0
  110. lfx/components/Notion/list_pages.py +122 -0
  111. lfx/components/Notion/list_users.py +77 -0
  112. lfx/components/Notion/page_content_viewer.py +93 -0
  113. lfx/components/Notion/search.py +111 -0
  114. lfx/components/Notion/update_page_property.py +114 -0
  115. lfx/components/__init__.py +428 -0
  116. lfx/components/_importing.py +42 -0
  117. lfx/components/agentql/__init__.py +3 -0
  118. lfx/components/agentql/agentql_api.py +151 -0
  119. lfx/components/aiml/__init__.py +37 -0
  120. lfx/components/aiml/aiml.py +115 -0
  121. lfx/components/aiml/aiml_embeddings.py +37 -0
  122. lfx/components/altk/__init__.py +34 -0
  123. lfx/components/altk/altk_agent.py +193 -0
  124. lfx/components/amazon/__init__.py +36 -0
  125. lfx/components/amazon/amazon_bedrock_converse.py +195 -0
  126. lfx/components/amazon/amazon_bedrock_embedding.py +109 -0
  127. lfx/components/amazon/amazon_bedrock_model.py +130 -0
  128. lfx/components/amazon/s3_bucket_uploader.py +211 -0
  129. lfx/components/anthropic/__init__.py +34 -0
  130. lfx/components/anthropic/anthropic.py +187 -0
  131. lfx/components/apify/__init__.py +5 -0
  132. lfx/components/apify/apify_actor.py +325 -0
  133. lfx/components/arxiv/__init__.py +3 -0
  134. lfx/components/arxiv/arxiv.py +169 -0
  135. lfx/components/assemblyai/__init__.py +46 -0
  136. lfx/components/assemblyai/assemblyai_get_subtitles.py +83 -0
  137. lfx/components/assemblyai/assemblyai_lemur.py +183 -0
  138. lfx/components/assemblyai/assemblyai_list_transcripts.py +95 -0
  139. lfx/components/assemblyai/assemblyai_poll_transcript.py +72 -0
  140. lfx/components/assemblyai/assemblyai_start_transcript.py +188 -0
  141. lfx/components/azure/__init__.py +37 -0
  142. lfx/components/azure/azure_openai.py +95 -0
  143. lfx/components/azure/azure_openai_embeddings.py +83 -0
  144. lfx/components/baidu/__init__.py +32 -0
  145. lfx/components/baidu/baidu_qianfan_chat.py +113 -0
  146. lfx/components/bing/__init__.py +3 -0
  147. lfx/components/bing/bing_search_api.py +61 -0
  148. lfx/components/cassandra/__init__.py +40 -0
  149. lfx/components/cassandra/cassandra.py +264 -0
  150. lfx/components/cassandra/cassandra_chat.py +92 -0
  151. lfx/components/cassandra/cassandra_graph.py +238 -0
  152. lfx/components/chains/__init__.py +3 -0
  153. lfx/components/chroma/__init__.py +34 -0
  154. lfx/components/chroma/chroma.py +169 -0
  155. lfx/components/cleanlab/__init__.py +40 -0
  156. lfx/components/cleanlab/cleanlab_evaluator.py +155 -0
  157. lfx/components/cleanlab/cleanlab_rag_evaluator.py +254 -0
  158. lfx/components/cleanlab/cleanlab_remediator.py +131 -0
  159. lfx/components/clickhouse/__init__.py +34 -0
  160. lfx/components/clickhouse/clickhouse.py +135 -0
  161. lfx/components/cloudflare/__init__.py +32 -0
  162. lfx/components/cloudflare/cloudflare.py +81 -0
  163. lfx/components/cohere/__init__.py +40 -0
  164. lfx/components/cohere/cohere_embeddings.py +81 -0
  165. lfx/components/cohere/cohere_models.py +46 -0
  166. lfx/components/cohere/cohere_rerank.py +51 -0
  167. lfx/components/cometapi/__init__.py +32 -0
  168. lfx/components/cometapi/cometapi.py +166 -0
  169. lfx/components/composio/__init__.py +222 -0
  170. lfx/components/composio/agentql_composio.py +11 -0
  171. lfx/components/composio/agiled_composio.py +11 -0
  172. lfx/components/composio/airtable_composio.py +11 -0
  173. lfx/components/composio/apollo_composio.py +11 -0
  174. lfx/components/composio/asana_composio.py +11 -0
  175. lfx/components/composio/attio_composio.py +11 -0
  176. lfx/components/composio/bitbucket_composio.py +11 -0
  177. lfx/components/composio/bolna_composio.py +11 -0
  178. lfx/components/composio/brightdata_composio.py +11 -0
  179. lfx/components/composio/calendly_composio.py +11 -0
  180. lfx/components/composio/canva_composio.py +11 -0
  181. lfx/components/composio/canvas_composio.py +11 -0
  182. lfx/components/composio/coda_composio.py +11 -0
  183. lfx/components/composio/composio_api.py +278 -0
  184. lfx/components/composio/contentful_composio.py +11 -0
  185. lfx/components/composio/digicert_composio.py +11 -0
  186. lfx/components/composio/discord_composio.py +11 -0
  187. lfx/components/composio/dropbox_compnent.py +11 -0
  188. lfx/components/composio/elevenlabs_composio.py +11 -0
  189. lfx/components/composio/exa_composio.py +11 -0
  190. lfx/components/composio/figma_composio.py +11 -0
  191. lfx/components/composio/finage_composio.py +11 -0
  192. lfx/components/composio/firecrawl_composio.py +11 -0
  193. lfx/components/composio/fireflies_composio.py +11 -0
  194. lfx/components/composio/fixer_composio.py +11 -0
  195. lfx/components/composio/flexisign_composio.py +11 -0
  196. lfx/components/composio/freshdesk_composio.py +11 -0
  197. lfx/components/composio/github_composio.py +11 -0
  198. lfx/components/composio/gmail_composio.py +38 -0
  199. lfx/components/composio/googlebigquery_composio.py +11 -0
  200. lfx/components/composio/googlecalendar_composio.py +11 -0
  201. lfx/components/composio/googleclassroom_composio.py +11 -0
  202. lfx/components/composio/googledocs_composio.py +11 -0
  203. lfx/components/composio/googlemeet_composio.py +11 -0
  204. lfx/components/composio/googlesheets_composio.py +11 -0
  205. lfx/components/composio/googletasks_composio.py +8 -0
  206. lfx/components/composio/heygen_composio.py +11 -0
  207. lfx/components/composio/instagram_composio.py +11 -0
  208. lfx/components/composio/jira_composio.py +11 -0
  209. lfx/components/composio/jotform_composio.py +11 -0
  210. lfx/components/composio/klaviyo_composio.py +11 -0
  211. lfx/components/composio/linear_composio.py +11 -0
  212. lfx/components/composio/listennotes_composio.py +11 -0
  213. lfx/components/composio/mem0_composio.py +11 -0
  214. lfx/components/composio/miro_composio.py +11 -0
  215. lfx/components/composio/missive_composio.py +11 -0
  216. lfx/components/composio/notion_composio.py +11 -0
  217. lfx/components/composio/onedrive_composio.py +11 -0
  218. lfx/components/composio/outlook_composio.py +11 -0
  219. lfx/components/composio/pandadoc_composio.py +11 -0
  220. lfx/components/composio/peopledatalabs_composio.py +11 -0
  221. lfx/components/composio/perplexityai_composio.py +11 -0
  222. lfx/components/composio/reddit_composio.py +11 -0
  223. lfx/components/composio/serpapi_composio.py +11 -0
  224. lfx/components/composio/slack_composio.py +11 -0
  225. lfx/components/composio/slackbot_composio.py +11 -0
  226. lfx/components/composio/snowflake_composio.py +11 -0
  227. lfx/components/composio/supabase_composio.py +11 -0
  228. lfx/components/composio/tavily_composio.py +11 -0
  229. lfx/components/composio/timelinesai_composio.py +11 -0
  230. lfx/components/composio/todoist_composio.py +11 -0
  231. lfx/components/composio/wrike_composio.py +11 -0
  232. lfx/components/composio/youtube_composio.py +11 -0
  233. lfx/components/confluence/__init__.py +3 -0
  234. lfx/components/confluence/confluence.py +84 -0
  235. lfx/components/couchbase/__init__.py +34 -0
  236. lfx/components/couchbase/couchbase.py +102 -0
  237. lfx/components/crewai/__init__.py +49 -0
  238. lfx/components/crewai/crewai.py +108 -0
  239. lfx/components/crewai/hierarchical_crew.py +47 -0
  240. lfx/components/crewai/hierarchical_task.py +45 -0
  241. lfx/components/crewai/sequential_crew.py +53 -0
  242. lfx/components/crewai/sequential_task.py +74 -0
  243. lfx/components/crewai/sequential_task_agent.py +144 -0
  244. lfx/components/cuga/__init__.py +34 -0
  245. lfx/components/cuga/cuga_agent.py +730 -0
  246. lfx/components/custom_component/__init__.py +34 -0
  247. lfx/components/custom_component/custom_component.py +31 -0
  248. lfx/components/data/__init__.py +114 -0
  249. lfx/components/data_source/__init__.py +58 -0
  250. lfx/components/data_source/api_request.py +577 -0
  251. lfx/components/data_source/csv_to_data.py +101 -0
  252. lfx/components/data_source/json_to_data.py +106 -0
  253. lfx/components/data_source/mock_data.py +398 -0
  254. lfx/components/data_source/news_search.py +166 -0
  255. lfx/components/data_source/rss.py +71 -0
  256. lfx/components/data_source/sql_executor.py +101 -0
  257. lfx/components/data_source/url.py +311 -0
  258. lfx/components/data_source/web_search.py +326 -0
  259. lfx/components/datastax/__init__.py +76 -0
  260. lfx/components/datastax/astradb_assistant_manager.py +307 -0
  261. lfx/components/datastax/astradb_chatmemory.py +40 -0
  262. lfx/components/datastax/astradb_cql.py +288 -0
  263. lfx/components/datastax/astradb_graph.py +217 -0
  264. lfx/components/datastax/astradb_tool.py +378 -0
  265. lfx/components/datastax/astradb_vectorize.py +122 -0
  266. lfx/components/datastax/astradb_vectorstore.py +449 -0
  267. lfx/components/datastax/create_assistant.py +59 -0
  268. lfx/components/datastax/create_thread.py +33 -0
  269. lfx/components/datastax/dotenv.py +36 -0
  270. lfx/components/datastax/get_assistant.py +38 -0
  271. lfx/components/datastax/getenvvar.py +31 -0
  272. lfx/components/datastax/graph_rag.py +141 -0
  273. lfx/components/datastax/hcd.py +315 -0
  274. lfx/components/datastax/list_assistants.py +26 -0
  275. lfx/components/datastax/run.py +90 -0
  276. lfx/components/deactivated/__init__.py +15 -0
  277. lfx/components/deactivated/amazon_kendra.py +66 -0
  278. lfx/components/deactivated/chat_litellm_model.py +158 -0
  279. lfx/components/deactivated/code_block_extractor.py +26 -0
  280. lfx/components/deactivated/documents_to_data.py +22 -0
  281. lfx/components/deactivated/embed.py +16 -0
  282. lfx/components/deactivated/extract_key_from_data.py +46 -0
  283. lfx/components/deactivated/json_document_builder.py +57 -0
  284. lfx/components/deactivated/list_flows.py +20 -0
  285. lfx/components/deactivated/mcp_sse.py +61 -0
  286. lfx/components/deactivated/mcp_stdio.py +62 -0
  287. lfx/components/deactivated/merge_data.py +93 -0
  288. lfx/components/deactivated/message.py +37 -0
  289. lfx/components/deactivated/metal.py +54 -0
  290. lfx/components/deactivated/multi_query.py +59 -0
  291. lfx/components/deactivated/retriever.py +43 -0
  292. lfx/components/deactivated/selective_passthrough.py +77 -0
  293. lfx/components/deactivated/should_run_next.py +40 -0
  294. lfx/components/deactivated/split_text.py +63 -0
  295. lfx/components/deactivated/store_message.py +24 -0
  296. lfx/components/deactivated/sub_flow.py +124 -0
  297. lfx/components/deactivated/vectara_self_query.py +76 -0
  298. lfx/components/deactivated/vector_store.py +24 -0
  299. lfx/components/deepseek/__init__.py +34 -0
  300. lfx/components/deepseek/deepseek.py +136 -0
  301. lfx/components/docling/__init__.py +43 -0
  302. lfx/components/docling/chunk_docling_document.py +186 -0
  303. lfx/components/docling/docling_inline.py +238 -0
  304. lfx/components/docling/docling_remote.py +195 -0
  305. lfx/components/docling/export_docling_document.py +117 -0
  306. lfx/components/documentloaders/__init__.py +3 -0
  307. lfx/components/duckduckgo/__init__.py +3 -0
  308. lfx/components/duckduckgo/duck_duck_go_search_run.py +92 -0
  309. lfx/components/elastic/__init__.py +37 -0
  310. lfx/components/elastic/elasticsearch.py +267 -0
  311. lfx/components/elastic/opensearch.py +789 -0
  312. lfx/components/elastic/opensearch_multimodal.py +1575 -0
  313. lfx/components/embeddings/__init__.py +37 -0
  314. lfx/components/embeddings/similarity.py +77 -0
  315. lfx/components/embeddings/text_embedder.py +65 -0
  316. lfx/components/exa/__init__.py +3 -0
  317. lfx/components/exa/exa_search.py +68 -0
  318. lfx/components/files_and_knowledge/__init__.py +47 -0
  319. lfx/components/files_and_knowledge/directory.py +113 -0
  320. lfx/components/files_and_knowledge/file.py +841 -0
  321. lfx/components/files_and_knowledge/ingestion.py +694 -0
  322. lfx/components/files_and_knowledge/retrieval.py +264 -0
  323. lfx/components/files_and_knowledge/save_file.py +746 -0
  324. lfx/components/firecrawl/__init__.py +43 -0
  325. lfx/components/firecrawl/firecrawl_crawl_api.py +88 -0
  326. lfx/components/firecrawl/firecrawl_extract_api.py +136 -0
  327. lfx/components/firecrawl/firecrawl_map_api.py +89 -0
  328. lfx/components/firecrawl/firecrawl_scrape_api.py +73 -0
  329. lfx/components/flow_controls/__init__.py +58 -0
  330. lfx/components/flow_controls/conditional_router.py +208 -0
  331. lfx/components/flow_controls/data_conditional_router.py +126 -0
  332. lfx/components/flow_controls/flow_tool.py +111 -0
  333. lfx/components/flow_controls/listen.py +29 -0
  334. lfx/components/flow_controls/loop.py +163 -0
  335. lfx/components/flow_controls/notify.py +88 -0
  336. lfx/components/flow_controls/pass_message.py +36 -0
  337. lfx/components/flow_controls/run_flow.py +108 -0
  338. lfx/components/flow_controls/sub_flow.py +115 -0
  339. lfx/components/git/__init__.py +4 -0
  340. lfx/components/git/git.py +262 -0
  341. lfx/components/git/gitextractor.py +196 -0
  342. lfx/components/glean/__init__.py +3 -0
  343. lfx/components/glean/glean_search_api.py +173 -0
  344. lfx/components/google/__init__.py +17 -0
  345. lfx/components/google/gmail.py +193 -0
  346. lfx/components/google/google_bq_sql_executor.py +157 -0
  347. lfx/components/google/google_drive.py +92 -0
  348. lfx/components/google/google_drive_search.py +152 -0
  349. lfx/components/google/google_generative_ai.py +144 -0
  350. lfx/components/google/google_generative_ai_embeddings.py +141 -0
  351. lfx/components/google/google_oauth_token.py +89 -0
  352. lfx/components/google/google_search_api_core.py +68 -0
  353. lfx/components/google/google_serper_api_core.py +74 -0
  354. lfx/components/groq/__init__.py +34 -0
  355. lfx/components/groq/groq.py +143 -0
  356. lfx/components/helpers/__init__.py +154 -0
  357. lfx/components/homeassistant/__init__.py +7 -0
  358. lfx/components/homeassistant/home_assistant_control.py +152 -0
  359. lfx/components/homeassistant/list_home_assistant_states.py +137 -0
  360. lfx/components/huggingface/__init__.py +37 -0
  361. lfx/components/huggingface/huggingface.py +199 -0
  362. lfx/components/huggingface/huggingface_inference_api.py +106 -0
  363. lfx/components/ibm/__init__.py +34 -0
  364. lfx/components/ibm/watsonx.py +207 -0
  365. lfx/components/ibm/watsonx_embeddings.py +135 -0
  366. lfx/components/icosacomputing/__init__.py +5 -0
  367. lfx/components/icosacomputing/combinatorial_reasoner.py +84 -0
  368. lfx/components/input_output/__init__.py +40 -0
  369. lfx/components/input_output/chat.py +109 -0
  370. lfx/components/input_output/chat_output.py +184 -0
  371. lfx/components/input_output/text.py +27 -0
  372. lfx/components/input_output/text_output.py +29 -0
  373. lfx/components/input_output/webhook.py +56 -0
  374. lfx/components/jigsawstack/__init__.py +23 -0
  375. lfx/components/jigsawstack/ai_scrape.py +126 -0
  376. lfx/components/jigsawstack/ai_web_search.py +136 -0
  377. lfx/components/jigsawstack/file_read.py +115 -0
  378. lfx/components/jigsawstack/file_upload.py +94 -0
  379. lfx/components/jigsawstack/image_generation.py +205 -0
  380. lfx/components/jigsawstack/nsfw.py +60 -0
  381. lfx/components/jigsawstack/object_detection.py +124 -0
  382. lfx/components/jigsawstack/sentiment.py +112 -0
  383. lfx/components/jigsawstack/text_to_sql.py +90 -0
  384. lfx/components/jigsawstack/text_translate.py +77 -0
  385. lfx/components/jigsawstack/vocr.py +107 -0
  386. lfx/components/knowledge_bases/__init__.py +89 -0
  387. lfx/components/langchain_utilities/__init__.py +109 -0
  388. lfx/components/langchain_utilities/character.py +53 -0
  389. lfx/components/langchain_utilities/conversation.py +59 -0
  390. lfx/components/langchain_utilities/csv_agent.py +175 -0
  391. lfx/components/langchain_utilities/fake_embeddings.py +26 -0
  392. lfx/components/langchain_utilities/html_link_extractor.py +35 -0
  393. lfx/components/langchain_utilities/json_agent.py +100 -0
  394. lfx/components/langchain_utilities/langchain_hub.py +126 -0
  395. lfx/components/langchain_utilities/language_recursive.py +49 -0
  396. lfx/components/langchain_utilities/language_semantic.py +138 -0
  397. lfx/components/langchain_utilities/llm_checker.py +39 -0
  398. lfx/components/langchain_utilities/llm_math.py +42 -0
  399. lfx/components/langchain_utilities/natural_language.py +61 -0
  400. lfx/components/langchain_utilities/openai_tools.py +53 -0
  401. lfx/components/langchain_utilities/openapi.py +48 -0
  402. lfx/components/langchain_utilities/recursive_character.py +60 -0
  403. lfx/components/langchain_utilities/retrieval_qa.py +83 -0
  404. lfx/components/langchain_utilities/runnable_executor.py +137 -0
  405. lfx/components/langchain_utilities/self_query.py +80 -0
  406. lfx/components/langchain_utilities/spider.py +142 -0
  407. lfx/components/langchain_utilities/sql.py +40 -0
  408. lfx/components/langchain_utilities/sql_database.py +35 -0
  409. lfx/components/langchain_utilities/sql_generator.py +78 -0
  410. lfx/components/langchain_utilities/tool_calling.py +59 -0
  411. lfx/components/langchain_utilities/vector_store_info.py +49 -0
  412. lfx/components/langchain_utilities/vector_store_router.py +33 -0
  413. lfx/components/langchain_utilities/xml_agent.py +71 -0
  414. lfx/components/langwatch/__init__.py +3 -0
  415. lfx/components/langwatch/langwatch.py +278 -0
  416. lfx/components/link_extractors/__init__.py +3 -0
  417. lfx/components/llm_operations/__init__.py +46 -0
  418. lfx/components/llm_operations/batch_run.py +205 -0
  419. lfx/components/llm_operations/lambda_filter.py +218 -0
  420. lfx/components/llm_operations/llm_conditional_router.py +421 -0
  421. lfx/components/llm_operations/llm_selector.py +499 -0
  422. lfx/components/llm_operations/structured_output.py +244 -0
  423. lfx/components/lmstudio/__init__.py +34 -0
  424. lfx/components/lmstudio/lmstudioembeddings.py +89 -0
  425. lfx/components/lmstudio/lmstudiomodel.py +133 -0
  426. lfx/components/logic/__init__.py +181 -0
  427. lfx/components/maritalk/__init__.py +32 -0
  428. lfx/components/maritalk/maritalk.py +52 -0
  429. lfx/components/mem0/__init__.py +3 -0
  430. lfx/components/mem0/mem0_chat_memory.py +147 -0
  431. lfx/components/milvus/__init__.py +34 -0
  432. lfx/components/milvus/milvus.py +115 -0
  433. lfx/components/mistral/__init__.py +37 -0
  434. lfx/components/mistral/mistral.py +114 -0
  435. lfx/components/mistral/mistral_embeddings.py +58 -0
  436. lfx/components/models/__init__.py +89 -0
  437. lfx/components/models_and_agents/__init__.py +49 -0
  438. lfx/components/models_and_agents/agent.py +644 -0
  439. lfx/components/models_and_agents/embedding_model.py +423 -0
  440. lfx/components/models_and_agents/language_model.py +398 -0
  441. lfx/components/models_and_agents/mcp_component.py +594 -0
  442. lfx/components/models_and_agents/memory.py +268 -0
  443. lfx/components/models_and_agents/prompt.py +67 -0
  444. lfx/components/mongodb/__init__.py +34 -0
  445. lfx/components/mongodb/mongodb_atlas.py +213 -0
  446. lfx/components/needle/__init__.py +3 -0
  447. lfx/components/needle/needle.py +104 -0
  448. lfx/components/notdiamond/__init__.py +34 -0
  449. lfx/components/notdiamond/notdiamond.py +228 -0
  450. lfx/components/novita/__init__.py +32 -0
  451. lfx/components/novita/novita.py +130 -0
  452. lfx/components/nvidia/__init__.py +57 -0
  453. lfx/components/nvidia/nvidia.py +151 -0
  454. lfx/components/nvidia/nvidia_embedding.py +77 -0
  455. lfx/components/nvidia/nvidia_ingest.py +317 -0
  456. lfx/components/nvidia/nvidia_rerank.py +63 -0
  457. lfx/components/nvidia/system_assist.py +65 -0
  458. lfx/components/olivya/__init__.py +3 -0
  459. lfx/components/olivya/olivya.py +116 -0
  460. lfx/components/ollama/__init__.py +37 -0
  461. lfx/components/ollama/ollama.py +548 -0
  462. lfx/components/ollama/ollama_embeddings.py +103 -0
  463. lfx/components/openai/__init__.py +37 -0
  464. lfx/components/openai/openai.py +100 -0
  465. lfx/components/openai/openai_chat_model.py +176 -0
  466. lfx/components/openrouter/__init__.py +32 -0
  467. lfx/components/openrouter/openrouter.py +104 -0
  468. lfx/components/output_parsers/__init__.py +3 -0
  469. lfx/components/perplexity/__init__.py +34 -0
  470. lfx/components/perplexity/perplexity.py +75 -0
  471. lfx/components/pgvector/__init__.py +34 -0
  472. lfx/components/pgvector/pgvector.py +72 -0
  473. lfx/components/pinecone/__init__.py +34 -0
  474. lfx/components/pinecone/pinecone.py +134 -0
  475. lfx/components/processing/__init__.py +72 -0
  476. lfx/components/processing/alter_metadata.py +109 -0
  477. lfx/components/processing/combine_text.py +40 -0
  478. lfx/components/processing/converter.py +248 -0
  479. lfx/components/processing/create_data.py +111 -0
  480. lfx/components/processing/create_list.py +40 -0
  481. lfx/components/processing/data_operations.py +528 -0
  482. lfx/components/processing/data_to_dataframe.py +71 -0
  483. lfx/components/processing/dataframe_operations.py +313 -0
  484. lfx/components/processing/dataframe_to_toolset.py +259 -0
  485. lfx/components/processing/dynamic_create_data.py +357 -0
  486. lfx/components/processing/extract_key.py +54 -0
  487. lfx/components/processing/filter_data.py +43 -0
  488. lfx/components/processing/filter_data_values.py +89 -0
  489. lfx/components/processing/json_cleaner.py +104 -0
  490. lfx/components/processing/merge_data.py +91 -0
  491. lfx/components/processing/message_to_data.py +37 -0
  492. lfx/components/processing/output_parser.py +46 -0
  493. lfx/components/processing/parse_data.py +71 -0
  494. lfx/components/processing/parse_dataframe.py +69 -0
  495. lfx/components/processing/parse_json_data.py +91 -0
  496. lfx/components/processing/parser.py +148 -0
  497. lfx/components/processing/regex.py +83 -0
  498. lfx/components/processing/select_data.py +49 -0
  499. lfx/components/processing/split_text.py +141 -0
  500. lfx/components/processing/store_message.py +91 -0
  501. lfx/components/processing/update_data.py +161 -0
  502. lfx/components/prototypes/__init__.py +35 -0
  503. lfx/components/prototypes/python_function.py +73 -0
  504. lfx/components/qdrant/__init__.py +34 -0
  505. lfx/components/qdrant/qdrant.py +109 -0
  506. lfx/components/redis/__init__.py +37 -0
  507. lfx/components/redis/redis.py +89 -0
  508. lfx/components/redis/redis_chat.py +43 -0
  509. lfx/components/sambanova/__init__.py +32 -0
  510. lfx/components/sambanova/sambanova.py +84 -0
  511. lfx/components/scrapegraph/__init__.py +40 -0
  512. lfx/components/scrapegraph/scrapegraph_markdownify_api.py +64 -0
  513. lfx/components/scrapegraph/scrapegraph_search_api.py +64 -0
  514. lfx/components/scrapegraph/scrapegraph_smart_scraper_api.py +71 -0
  515. lfx/components/searchapi/__init__.py +34 -0
  516. lfx/components/searchapi/search.py +79 -0
  517. lfx/components/serpapi/__init__.py +3 -0
  518. lfx/components/serpapi/serp.py +115 -0
  519. lfx/components/supabase/__init__.py +34 -0
  520. lfx/components/supabase/supabase.py +76 -0
  521. lfx/components/tavily/__init__.py +4 -0
  522. lfx/components/tavily/tavily_extract.py +117 -0
  523. lfx/components/tavily/tavily_search.py +212 -0
  524. lfx/components/textsplitters/__init__.py +3 -0
  525. lfx/components/toolkits/__init__.py +3 -0
  526. lfx/components/tools/__init__.py +66 -0
  527. lfx/components/tools/calculator.py +109 -0
  528. lfx/components/tools/google_search_api.py +45 -0
  529. lfx/components/tools/google_serper_api.py +115 -0
  530. lfx/components/tools/python_code_structured_tool.py +328 -0
  531. lfx/components/tools/python_repl.py +98 -0
  532. lfx/components/tools/search_api.py +88 -0
  533. lfx/components/tools/searxng.py +145 -0
  534. lfx/components/tools/serp_api.py +120 -0
  535. lfx/components/tools/tavily_search_tool.py +345 -0
  536. lfx/components/tools/wikidata_api.py +103 -0
  537. lfx/components/tools/wikipedia_api.py +50 -0
  538. lfx/components/tools/yahoo_finance.py +130 -0
  539. lfx/components/twelvelabs/__init__.py +52 -0
  540. lfx/components/twelvelabs/convert_astra_results.py +84 -0
  541. lfx/components/twelvelabs/pegasus_index.py +311 -0
  542. lfx/components/twelvelabs/split_video.py +301 -0
  543. lfx/components/twelvelabs/text_embeddings.py +57 -0
  544. lfx/components/twelvelabs/twelvelabs_pegasus.py +408 -0
  545. lfx/components/twelvelabs/video_embeddings.py +100 -0
  546. lfx/components/twelvelabs/video_file.py +191 -0
  547. lfx/components/unstructured/__init__.py +3 -0
  548. lfx/components/unstructured/unstructured.py +121 -0
  549. lfx/components/upstash/__init__.py +34 -0
  550. lfx/components/upstash/upstash.py +124 -0
  551. lfx/components/utilities/__init__.py +43 -0
  552. lfx/components/utilities/calculator_core.py +89 -0
  553. lfx/components/utilities/current_date.py +42 -0
  554. lfx/components/utilities/id_generator.py +42 -0
  555. lfx/components/utilities/python_repl_core.py +98 -0
  556. lfx/components/vectara/__init__.py +37 -0
  557. lfx/components/vectara/vectara.py +97 -0
  558. lfx/components/vectara/vectara_rag.py +164 -0
  559. lfx/components/vectorstores/__init__.py +34 -0
  560. lfx/components/vectorstores/local_db.py +270 -0
  561. lfx/components/vertexai/__init__.py +37 -0
  562. lfx/components/vertexai/vertexai.py +71 -0
  563. lfx/components/vertexai/vertexai_embeddings.py +67 -0
  564. lfx/components/vlmrun/__init__.py +34 -0
  565. lfx/components/vlmrun/vlmrun_transcription.py +224 -0
  566. lfx/components/weaviate/__init__.py +34 -0
  567. lfx/components/weaviate/weaviate.py +89 -0
  568. lfx/components/wikipedia/__init__.py +4 -0
  569. lfx/components/wikipedia/wikidata.py +86 -0
  570. lfx/components/wikipedia/wikipedia.py +53 -0
  571. lfx/components/wolframalpha/__init__.py +3 -0
  572. lfx/components/wolframalpha/wolfram_alpha_api.py +54 -0
  573. lfx/components/xai/__init__.py +32 -0
  574. lfx/components/xai/xai.py +167 -0
  575. lfx/components/yahoosearch/__init__.py +3 -0
  576. lfx/components/yahoosearch/yahoo.py +137 -0
  577. lfx/components/youtube/__init__.py +52 -0
  578. lfx/components/youtube/channel.py +227 -0
  579. lfx/components/youtube/comments.py +231 -0
  580. lfx/components/youtube/playlist.py +33 -0
  581. lfx/components/youtube/search.py +120 -0
  582. lfx/components/youtube/trending.py +285 -0
  583. lfx/components/youtube/video_details.py +263 -0
  584. lfx/components/youtube/youtube_transcripts.py +206 -0
  585. lfx/components/zep/__init__.py +3 -0
  586. lfx/components/zep/zep.py +45 -0
  587. lfx/constants.py +6 -0
  588. lfx/custom/__init__.py +7 -0
  589. lfx/custom/attributes.py +87 -0
  590. lfx/custom/code_parser/__init__.py +3 -0
  591. lfx/custom/code_parser/code_parser.py +361 -0
  592. lfx/custom/custom_component/__init__.py +0 -0
  593. lfx/custom/custom_component/base_component.py +128 -0
  594. lfx/custom/custom_component/component.py +1890 -0
  595. lfx/custom/custom_component/component_with_cache.py +8 -0
  596. lfx/custom/custom_component/custom_component.py +650 -0
  597. lfx/custom/dependency_analyzer.py +165 -0
  598. lfx/custom/directory_reader/__init__.py +3 -0
  599. lfx/custom/directory_reader/directory_reader.py +359 -0
  600. lfx/custom/directory_reader/utils.py +171 -0
  601. lfx/custom/eval.py +12 -0
  602. lfx/custom/schema.py +32 -0
  603. lfx/custom/tree_visitor.py +21 -0
  604. lfx/custom/utils.py +877 -0
  605. lfx/custom/validate.py +523 -0
  606. lfx/events/__init__.py +1 -0
  607. lfx/events/event_manager.py +110 -0
  608. lfx/exceptions/__init__.py +0 -0
  609. lfx/exceptions/component.py +15 -0
  610. lfx/field_typing/__init__.py +91 -0
  611. lfx/field_typing/constants.py +216 -0
  612. lfx/field_typing/range_spec.py +35 -0
  613. lfx/graph/__init__.py +6 -0
  614. lfx/graph/edge/__init__.py +0 -0
  615. lfx/graph/edge/base.py +300 -0
  616. lfx/graph/edge/schema.py +119 -0
  617. lfx/graph/edge/utils.py +0 -0
  618. lfx/graph/graph/__init__.py +0 -0
  619. lfx/graph/graph/ascii.py +202 -0
  620. lfx/graph/graph/base.py +2298 -0
  621. lfx/graph/graph/constants.py +63 -0
  622. lfx/graph/graph/runnable_vertices_manager.py +133 -0
  623. lfx/graph/graph/schema.py +53 -0
  624. lfx/graph/graph/state_model.py +66 -0
  625. lfx/graph/graph/utils.py +1024 -0
  626. lfx/graph/schema.py +75 -0
  627. lfx/graph/state/__init__.py +0 -0
  628. lfx/graph/state/model.py +250 -0
  629. lfx/graph/utils.py +206 -0
  630. lfx/graph/vertex/__init__.py +0 -0
  631. lfx/graph/vertex/base.py +826 -0
  632. lfx/graph/vertex/constants.py +0 -0
  633. lfx/graph/vertex/exceptions.py +4 -0
  634. lfx/graph/vertex/param_handler.py +316 -0
  635. lfx/graph/vertex/schema.py +26 -0
  636. lfx/graph/vertex/utils.py +19 -0
  637. lfx/graph/vertex/vertex_types.py +489 -0
  638. lfx/helpers/__init__.py +141 -0
  639. lfx/helpers/base_model.py +71 -0
  640. lfx/helpers/custom.py +13 -0
  641. lfx/helpers/data.py +167 -0
  642. lfx/helpers/flow.py +308 -0
  643. lfx/inputs/__init__.py +68 -0
  644. lfx/inputs/constants.py +2 -0
  645. lfx/inputs/input_mixin.py +352 -0
  646. lfx/inputs/inputs.py +718 -0
  647. lfx/inputs/validators.py +19 -0
  648. lfx/interface/__init__.py +6 -0
  649. lfx/interface/components.py +897 -0
  650. lfx/interface/importing/__init__.py +5 -0
  651. lfx/interface/importing/utils.py +39 -0
  652. lfx/interface/initialize/__init__.py +3 -0
  653. lfx/interface/initialize/loading.py +317 -0
  654. lfx/interface/listing.py +26 -0
  655. lfx/interface/run.py +16 -0
  656. lfx/interface/utils.py +111 -0
  657. lfx/io/__init__.py +63 -0
  658. lfx/io/schema.py +295 -0
  659. lfx/load/__init__.py +8 -0
  660. lfx/load/load.py +256 -0
  661. lfx/load/utils.py +99 -0
  662. lfx/log/__init__.py +5 -0
  663. lfx/log/logger.py +411 -0
  664. lfx/logging/__init__.py +11 -0
  665. lfx/logging/logger.py +24 -0
  666. lfx/memory/__init__.py +70 -0
  667. lfx/memory/stubs.py +302 -0
  668. lfx/processing/__init__.py +1 -0
  669. lfx/processing/process.py +238 -0
  670. lfx/processing/utils.py +25 -0
  671. lfx/py.typed +0 -0
  672. lfx/schema/__init__.py +66 -0
  673. lfx/schema/artifact.py +83 -0
  674. lfx/schema/content_block.py +62 -0
  675. lfx/schema/content_types.py +91 -0
  676. lfx/schema/cross_module.py +80 -0
  677. lfx/schema/data.py +309 -0
  678. lfx/schema/dataframe.py +210 -0
  679. lfx/schema/dotdict.py +74 -0
  680. lfx/schema/encoders.py +13 -0
  681. lfx/schema/graph.py +47 -0
  682. lfx/schema/image.py +184 -0
  683. lfx/schema/json_schema.py +186 -0
  684. lfx/schema/log.py +62 -0
  685. lfx/schema/message.py +493 -0
  686. lfx/schema/openai_responses_schemas.py +74 -0
  687. lfx/schema/properties.py +41 -0
  688. lfx/schema/schema.py +180 -0
  689. lfx/schema/serialize.py +13 -0
  690. lfx/schema/table.py +142 -0
  691. lfx/schema/validators.py +114 -0
  692. lfx/serialization/__init__.py +5 -0
  693. lfx/serialization/constants.py +2 -0
  694. lfx/serialization/serialization.py +314 -0
  695. lfx/services/__init__.py +26 -0
  696. lfx/services/base.py +28 -0
  697. lfx/services/cache/__init__.py +6 -0
  698. lfx/services/cache/base.py +183 -0
  699. lfx/services/cache/service.py +166 -0
  700. lfx/services/cache/utils.py +169 -0
  701. lfx/services/chat/__init__.py +1 -0
  702. lfx/services/chat/config.py +2 -0
  703. lfx/services/chat/schema.py +10 -0
  704. lfx/services/database/__init__.py +5 -0
  705. lfx/services/database/service.py +25 -0
  706. lfx/services/deps.py +194 -0
  707. lfx/services/factory.py +19 -0
  708. lfx/services/initialize.py +19 -0
  709. lfx/services/interfaces.py +103 -0
  710. lfx/services/manager.py +185 -0
  711. lfx/services/mcp_composer/__init__.py +6 -0
  712. lfx/services/mcp_composer/factory.py +16 -0
  713. lfx/services/mcp_composer/service.py +1441 -0
  714. lfx/services/schema.py +21 -0
  715. lfx/services/session.py +87 -0
  716. lfx/services/settings/__init__.py +3 -0
  717. lfx/services/settings/auth.py +133 -0
  718. lfx/services/settings/base.py +668 -0
  719. lfx/services/settings/constants.py +43 -0
  720. lfx/services/settings/factory.py +23 -0
  721. lfx/services/settings/feature_flags.py +11 -0
  722. lfx/services/settings/service.py +35 -0
  723. lfx/services/settings/utils.py +40 -0
  724. lfx/services/shared_component_cache/__init__.py +1 -0
  725. lfx/services/shared_component_cache/factory.py +30 -0
  726. lfx/services/shared_component_cache/service.py +9 -0
  727. lfx/services/storage/__init__.py +5 -0
  728. lfx/services/storage/local.py +185 -0
  729. lfx/services/storage/service.py +177 -0
  730. lfx/services/tracing/__init__.py +1 -0
  731. lfx/services/tracing/service.py +21 -0
  732. lfx/settings.py +6 -0
  733. lfx/template/__init__.py +6 -0
  734. lfx/template/field/__init__.py +0 -0
  735. lfx/template/field/base.py +260 -0
  736. lfx/template/field/prompt.py +15 -0
  737. lfx/template/frontend_node/__init__.py +6 -0
  738. lfx/template/frontend_node/base.py +214 -0
  739. lfx/template/frontend_node/constants.py +65 -0
  740. lfx/template/frontend_node/custom_components.py +79 -0
  741. lfx/template/template/__init__.py +0 -0
  742. lfx/template/template/base.py +100 -0
  743. lfx/template/utils.py +217 -0
  744. lfx/type_extraction/__init__.py +19 -0
  745. lfx/type_extraction/type_extraction.py +75 -0
  746. lfx/type_extraction.py +80 -0
  747. lfx/utils/__init__.py +1 -0
  748. lfx/utils/async_helpers.py +42 -0
  749. lfx/utils/component_utils.py +154 -0
  750. lfx/utils/concurrency.py +60 -0
  751. lfx/utils/connection_string_parser.py +11 -0
  752. lfx/utils/constants.py +233 -0
  753. lfx/utils/data_structure.py +212 -0
  754. lfx/utils/exceptions.py +22 -0
  755. lfx/utils/helpers.py +34 -0
  756. lfx/utils/image.py +79 -0
  757. lfx/utils/langflow_utils.py +52 -0
  758. lfx/utils/lazy_load.py +15 -0
  759. lfx/utils/request_utils.py +18 -0
  760. lfx/utils/schemas.py +139 -0
  761. lfx/utils/ssrf_protection.py +384 -0
  762. lfx/utils/util.py +626 -0
  763. lfx/utils/util_strings.py +56 -0
  764. lfx/utils/validate_cloud.py +26 -0
  765. lfx/utils/version.py +24 -0
  766. lfx_nightly-0.2.0.dev25.dist-info/METADATA +312 -0
  767. lfx_nightly-0.2.0.dev25.dist-info/RECORD +769 -0
  768. lfx_nightly-0.2.0.dev25.dist-info/WHEEL +4 -0
  769. lfx_nightly-0.2.0.dev25.dist-info/entry_points.txt +2 -0
lfx/base/mcp/util.py ADDED
@@ -0,0 +1,1659 @@
1
+ import asyncio
2
+ import contextlib
3
+ import inspect
4
+ import json
5
+ import os
6
+ import platform
7
+ import re
8
+ import shutil
9
+ import unicodedata
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+ from uuid import UUID
14
+
15
+ import httpx
16
+ from anyio import ClosedResourceError
17
+ from httpx import codes as httpx_codes
18
+ from langchain_core.tools import StructuredTool
19
+ from mcp import ClientSession
20
+ from mcp.shared.exceptions import McpError
21
+ from pydantic import BaseModel
22
+
23
+ from lfx.log.logger import logger
24
+ from lfx.schema.json_schema import create_input_schema_from_json_schema
25
+ from lfx.services.deps import get_settings_service
26
+
27
+ HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client errors
28
+
29
+ # HTTP status codes used in validation
30
+ HTTP_NOT_FOUND = 404
31
+ HTTP_METHOD_NOT_ALLOWED = 405
32
+ HTTP_NOT_ACCEPTABLE = 406
33
+ HTTP_BAD_REQUEST = 400
34
+ HTTP_INTERNAL_SERVER_ERROR = 500
35
+ HTTP_UNAUTHORIZED = 401
36
+ HTTP_FORBIDDEN = 403
37
+
38
+ # MCP Session Manager constants - lazy loaded
39
+ _mcp_settings_cache: dict[str, Any] = {}
40
+
41
+
42
+ def _get_mcp_setting(key: str, default: Any = None) -> Any:
43
+ """Lazy load MCP settings from settings service."""
44
+ if key not in _mcp_settings_cache:
45
+ settings = get_settings_service().settings
46
+ _mcp_settings_cache[key] = getattr(settings, key, default)
47
+ return _mcp_settings_cache[key]
48
+
49
+
50
+ def get_max_sessions_per_server() -> int:
51
+ """Get maximum number of sessions per server to prevent resource exhaustion."""
52
+ return _get_mcp_setting("mcp_max_sessions_per_server")
53
+
54
+
55
+ def get_session_idle_timeout() -> int:
56
+ """Get 5 minutes idle timeout for sessions."""
57
+ return _get_mcp_setting("mcp_session_idle_timeout")
58
+
59
+
60
+ def get_session_cleanup_interval() -> int:
61
+ """Get cleanup interval in seconds."""
62
+ return _get_mcp_setting("mcp_session_cleanup_interval")
63
+
64
+
65
+ # RFC 7230 compliant header name pattern: token = 1*tchar
66
+ # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
67
+ # "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
68
+ HEADER_NAME_PATTERN = re.compile(r"^[!#$%&\'*+\-.0-9A-Z^_`a-z|~]+$")
69
+
70
+ # Common allowed headers for MCP connections
71
+ ALLOWED_HEADERS = {
72
+ "authorization",
73
+ "accept",
74
+ "accept-encoding",
75
+ "accept-language",
76
+ "cache-control",
77
+ "content-type",
78
+ "user-agent",
79
+ "x-api-key",
80
+ "x-auth-token",
81
+ "x-custom-header",
82
+ "x-langflow-session",
83
+ "x-mcp-client",
84
+ "x-requested-with",
85
+ }
86
+
87
+
88
+ def create_mcp_http_client_with_ssl_option(
89
+ headers: dict[str, str] | None = None,
90
+ timeout: httpx.Timeout | None = None,
91
+ auth: httpx.Auth | None = None,
92
+ *,
93
+ verify_ssl: bool = True,
94
+ ) -> httpx.AsyncClient:
95
+ """Create an httpx AsyncClient with configurable SSL verification.
96
+
97
+ This is a custom factory that extends the standard MCP client factory
98
+ to support disabling SSL verification for self-signed certificates.
99
+
100
+ Args:
101
+ headers: Optional headers to include with all requests.
102
+ timeout: Request timeout as httpx.Timeout object.
103
+ auth: Optional authentication handler.
104
+ verify_ssl: Whether to verify SSL certificates (default: True).
105
+
106
+ Returns:
107
+ Configured httpx.AsyncClient instance.
108
+ """
109
+ kwargs: dict[str, Any] = {
110
+ "follow_redirects": True,
111
+ "verify": verify_ssl,
112
+ }
113
+
114
+ if timeout is None:
115
+ kwargs["timeout"] = httpx.Timeout(30.0)
116
+ else:
117
+ kwargs["timeout"] = timeout
118
+
119
+ if headers is not None:
120
+ kwargs["headers"] = headers
121
+
122
+ if auth is not None:
123
+ kwargs["auth"] = auth
124
+
125
+ return httpx.AsyncClient(**kwargs)
126
+
127
+
128
+ def validate_headers(headers: dict[str, str]) -> dict[str, str]:
129
+ """Validate and sanitize HTTP headers according to RFC 7230.
130
+
131
+ Args:
132
+ headers: Dictionary of header name-value pairs
133
+
134
+ Returns:
135
+ Dictionary of validated and sanitized headers
136
+
137
+ Raises:
138
+ ValueError: If headers contain invalid names or values
139
+ """
140
+ if not headers:
141
+ return {}
142
+
143
+ sanitized_headers = {}
144
+
145
+ for name, value in headers.items():
146
+ if not isinstance(name, str) or not isinstance(value, str):
147
+ logger.warning(f"Skipping non-string header: {name}={value}")
148
+ continue
149
+
150
+ # Validate header name according to RFC 7230
151
+ if not HEADER_NAME_PATTERN.match(name):
152
+ logger.warning(f"Invalid header name '{name}', skipping")
153
+ continue
154
+
155
+ # Normalize header name to lowercase (HTTP headers are case-insensitive)
156
+ normalized_name = name.lower()
157
+
158
+ # Optional: Check against whitelist of allowed headers
159
+ if normalized_name not in ALLOWED_HEADERS:
160
+ # For MCP, we'll be permissive and allow non-standard headers
161
+ # but log a warning for security awareness
162
+ logger.debug(f"Using non-standard header: {normalized_name}")
163
+
164
+ # Check for potential header injection attempts BEFORE sanitizing
165
+ if "\r" in value or "\n" in value:
166
+ logger.warning(f"Potential header injection detected in '{name}', skipping")
167
+ continue
168
+
169
+ # Sanitize header value - remove control characters and newlines
170
+ # RFC 7230: field-value = *( field-content / obs-fold )
171
+ # We'll remove control characters (0x00-0x1F, 0x7F) except tab (0x09) and space (0x20)
172
+ sanitized_value = re.sub(r"[\x00-\x08\x0A-\x1F\x7F]", "", value)
173
+
174
+ # Remove leading/trailing whitespace
175
+ sanitized_value = sanitized_value.strip()
176
+
177
+ if not sanitized_value:
178
+ logger.warning(f"Header '{name}' has empty value after sanitization, skipping")
179
+ continue
180
+
181
+ sanitized_headers[normalized_name] = sanitized_value
182
+
183
+ return sanitized_headers
184
+
185
+
186
+ def sanitize_mcp_name(name: str, max_length: int = 46) -> str:
187
+ """Sanitize a name for MCP usage by removing emojis, diacritics, and special characters.
188
+
189
+ Args:
190
+ name: The original name to sanitize
191
+ max_length: Maximum length for the sanitized name
192
+
193
+ Returns:
194
+ A sanitized name containing only letters, numbers, hyphens, and underscores
195
+ """
196
+ if not name or not name.strip():
197
+ return ""
198
+
199
+ # Remove emojis using regex pattern
200
+ emoji_pattern = re.compile(
201
+ "["
202
+ "\U0001f600-\U0001f64f" # emoticons
203
+ "\U0001f300-\U0001f5ff" # symbols & pictographs
204
+ "\U0001f680-\U0001f6ff" # transport & map symbols
205
+ "\U0001f1e0-\U0001f1ff" # flags (iOS)
206
+ "\U00002500-\U00002bef" # chinese char
207
+ "\U00002702-\U000027b0"
208
+ "\U00002702-\U000027b0"
209
+ "\U000024c2-\U0001f251"
210
+ "\U0001f926-\U0001f937"
211
+ "\U00010000-\U0010ffff"
212
+ "\u2640-\u2642"
213
+ "\u2600-\u2b55"
214
+ "\u200d"
215
+ "\u23cf"
216
+ "\u23e9"
217
+ "\u231a"
218
+ "\ufe0f" # dingbats
219
+ "\u3030"
220
+ "]+",
221
+ flags=re.UNICODE,
222
+ )
223
+
224
+ # Remove emojis
225
+ name = emoji_pattern.sub("", name)
226
+
227
+ # Normalize unicode characters to remove diacritics
228
+ name = unicodedata.normalize("NFD", name)
229
+ name = "".join(char for char in name if unicodedata.category(char) != "Mn")
230
+
231
+ # Replace spaces and special characters with underscores
232
+ name = re.sub(r"[^\w\s-]", "", name) # Keep only word chars, spaces, and hyphens
233
+ name = re.sub(r"[-\s]+", "_", name) # Replace spaces and hyphens with underscores
234
+ name = re.sub(r"_+", "_", name) # Collapse multiple underscores
235
+
236
+ # Remove leading/trailing underscores
237
+ name = name.strip("_")
238
+
239
+ # Ensure it starts with a letter or underscore (not a number)
240
+ if name and name[0].isdigit():
241
+ name = f"_{name}"
242
+
243
+ # Convert to lowercase
244
+ name = name.lower()
245
+
246
+ # Truncate to max length
247
+ if len(name) > max_length:
248
+ name = name[:max_length].rstrip("_")
249
+
250
+ # If empty after sanitization, provide a default
251
+ if not name:
252
+ name = "unnamed"
253
+
254
+ return name
255
+
256
+
257
+ def _camel_to_snake(name: str) -> str:
258
+ """Convert camelCase to snake_case."""
259
+ import re
260
+
261
+ # Insert an underscore before any uppercase letter that follows a lowercase letter
262
+ s1 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name)
263
+ return s1.lower()
264
+
265
+
266
+ def _convert_camel_case_to_snake_case(provided_args: dict[str, Any], arg_schema: type[BaseModel]) -> dict[str, Any]:
267
+ """Convert camelCase field names to snake_case if the schema expects snake_case fields."""
268
+ schema_fields = set(arg_schema.model_fields.keys())
269
+ converted_args = {}
270
+
271
+ for key, value in provided_args.items():
272
+ # If the key already exists in schema, use it as-is
273
+ if key in schema_fields:
274
+ converted_args[key] = value
275
+ else:
276
+ # Try converting camelCase to snake_case
277
+ snake_key = _camel_to_snake(key)
278
+ if snake_key in schema_fields:
279
+ converted_args[snake_key] = value
280
+ else:
281
+ # If neither the original nor converted key exists, keep original
282
+ # The validation will catch this error
283
+ converted_args[key] = value
284
+
285
+ return converted_args
286
+
287
+
288
+ def _handle_tool_validation_error(
289
+ e: Exception, tool_name: str, provided_args: dict[str, Any], arg_schema: type[BaseModel]
290
+ ) -> None:
291
+ """Handle validation errors for tool arguments with detailed error messages."""
292
+ # Check if this is a case where the tool was called with no arguments
293
+ if not provided_args and hasattr(arg_schema, "model_fields"):
294
+ required_fields = [name for name, field in arg_schema.model_fields.items() if field.is_required()]
295
+ if required_fields:
296
+ msg = (
297
+ f"Tool '{tool_name}' requires arguments but none were provided. "
298
+ f"Required fields: {', '.join(required_fields)}. "
299
+ f"Please check that the LLM is properly calling the tool with arguments."
300
+ )
301
+ raise ValueError(msg) from e
302
+ msg = f"Invalid input: {e}"
303
+ raise ValueError(msg) from e
304
+
305
+
306
+ def create_tool_coroutine(tool_name: str, arg_schema: type[BaseModel], client) -> Callable[..., Awaitable]:
307
+ async def tool_coroutine(*args, **kwargs):
308
+ # Get field names from the model (preserving order)
309
+ field_names = list(arg_schema.model_fields.keys())
310
+ provided_args = {}
311
+ # Map positional arguments to their corresponding field names
312
+ for i, arg in enumerate(args):
313
+ if i >= len(field_names):
314
+ msg = "Too many positional arguments provided"
315
+ raise ValueError(msg)
316
+ provided_args[field_names[i]] = arg
317
+ # Merge in keyword arguments
318
+ provided_args.update(kwargs)
319
+ provided_args = _convert_camel_case_to_snake_case(provided_args, arg_schema)
320
+ # Validate input and fill defaults for missing optional fields
321
+ try:
322
+ validated = arg_schema.model_validate(provided_args)
323
+ except Exception as e: # noqa: BLE001
324
+ _handle_tool_validation_error(e, tool_name, provided_args, arg_schema)
325
+
326
+ try:
327
+ return await client.run_tool(tool_name, arguments=validated.model_dump())
328
+ except Exception as e:
329
+ await logger.aerror(f"Tool '{tool_name}' execution failed: {e}")
330
+ # Re-raise with more context
331
+ msg = f"Tool '{tool_name}' execution failed: {e}"
332
+ raise ValueError(msg) from e
333
+
334
+ return tool_coroutine
335
+
336
+
337
+ def create_tool_func(tool_name: str, arg_schema: type[BaseModel], client) -> Callable[..., str]:
338
+ def tool_func(*args, **kwargs):
339
+ field_names = list(arg_schema.model_fields.keys())
340
+ provided_args = {}
341
+ for i, arg in enumerate(args):
342
+ if i >= len(field_names):
343
+ msg = "Too many positional arguments provided"
344
+ raise ValueError(msg)
345
+ provided_args[field_names[i]] = arg
346
+ provided_args.update(kwargs)
347
+ provided_args = _convert_camel_case_to_snake_case(provided_args, arg_schema)
348
+ try:
349
+ validated = arg_schema.model_validate(provided_args)
350
+ except Exception as e: # noqa: BLE001
351
+ _handle_tool_validation_error(e, tool_name, provided_args, arg_schema)
352
+
353
+ try:
354
+ loop = asyncio.get_event_loop()
355
+ return loop.run_until_complete(client.run_tool(tool_name, arguments=validated.model_dump()))
356
+ except Exception as e:
357
+ logger.error(f"Tool '{tool_name}' execution failed: {e}")
358
+ # Re-raise with more context
359
+ msg = f"Tool '{tool_name}' execution failed: {e}"
360
+ raise ValueError(msg) from e
361
+
362
+ return tool_func
363
+
364
+
365
+ def get_unique_name(base_name, max_length, existing_names):
366
+ name = base_name[:max_length]
367
+ if name not in existing_names:
368
+ return name
369
+ i = 1
370
+ while True:
371
+ suffix = f"_{i}"
372
+ truncated_base = base_name[: max_length - len(suffix)]
373
+ candidate = f"{truncated_base}{suffix}"
374
+ if candidate not in existing_names:
375
+ return candidate
376
+ i += 1
377
+
378
+
379
+ async def get_flow_snake_case(flow_name: str, user_id: str, session, *, is_action: bool | None = None):
380
+ try:
381
+ from langflow.services.database.models.flow.model import Flow
382
+ from sqlmodel import select
383
+ except ImportError as e:
384
+ msg = "Langflow Flow model is not available. This feature requires the full Langflow installation."
385
+ raise ImportError(msg) from e
386
+
387
+ uuid_user_id = UUID(user_id) if isinstance(user_id, str) else user_id
388
+
389
+ stmt = select(Flow).where(Flow.user_id == uuid_user_id).where(Flow.is_component == False) # noqa: E712
390
+ flows = (await session.exec(stmt)).all()
391
+
392
+ for flow in flows:
393
+ if is_action and flow.action_name:
394
+ this_flow_name = sanitize_mcp_name(flow.action_name)
395
+ else:
396
+ this_flow_name = sanitize_mcp_name(flow.name)
397
+
398
+ if this_flow_name == flow_name:
399
+ return flow
400
+ return None
401
+
402
+
403
+ def _is_valid_key_value_item(item: Any) -> bool:
404
+ """Check if an item is a valid key-value dictionary."""
405
+ return isinstance(item, dict) and "key" in item and "value" in item
406
+
407
+
408
+ def _process_headers(headers: Any) -> dict:
409
+ """Process the headers input into a valid dictionary.
410
+
411
+ Args:
412
+ headers: The headers to process, can be dict, str, or list
413
+ Returns:
414
+ Processed and validated dictionary
415
+ """
416
+ if headers is None:
417
+ return {}
418
+ if isinstance(headers, dict):
419
+ return validate_headers(headers)
420
+ if isinstance(headers, list):
421
+ processed_headers = {}
422
+ try:
423
+ for item in headers:
424
+ if not _is_valid_key_value_item(item):
425
+ continue
426
+ key = item["key"]
427
+ value = item["value"]
428
+ processed_headers[key] = value
429
+ except (KeyError, TypeError, ValueError):
430
+ return {} # Return empty dictionary instead of None
431
+ return validate_headers(processed_headers)
432
+ return {}
433
+
434
+
435
+ def _validate_node_installation(command: str) -> str:
436
+ """Validate the npx command."""
437
+ if "npx" in command and not shutil.which("node"):
438
+ msg = "Node.js is not installed. Please install Node.js to use npx commands."
439
+ raise ValueError(msg)
440
+ return command
441
+
442
+
443
+ async def _validate_connection_params(mode: str, command: str | None = None, url: str | None = None) -> None:
444
+ """Validate connection parameters based on mode."""
445
+ if mode not in ["Stdio", "Streamable_HTTP", "SSE"]:
446
+ msg = f"Invalid mode: {mode}. Must be either 'Stdio', 'Streamable_HTTP', or 'SSE'"
447
+ raise ValueError(msg)
448
+
449
+ if mode == "Stdio" and not command:
450
+ msg = "Command is required for Stdio mode"
451
+ raise ValueError(msg)
452
+ if mode == "Stdio" and command:
453
+ _validate_node_installation(command)
454
+ if mode in ["Streamable_HTTP", "SSE"] and not url:
455
+ msg = f"URL is required for {mode} mode"
456
+ raise ValueError(msg)
457
+
458
+
459
+ class MCPSessionManager:
460
+ """Manages persistent MCP sessions with proper context manager lifecycle.
461
+
462
+ Fixed version that addresses the memory leak issue by:
463
+ 1. Session reuse based on server identity rather than unique context IDs
464
+ 2. Maximum session limits per server to prevent resource exhaustion
465
+ 3. Idle timeout for automatic session cleanup
466
+ 4. Periodic cleanup of stale sessions
467
+ 5. Transport preference caching to avoid retrying failed transports
468
+ """
469
+
470
+ def __init__(self):
471
+ # Structure: server_key -> {"sessions": {session_id: session_info}, "last_cleanup": timestamp}
472
+ self.sessions_by_server = {}
473
+ self._background_tasks = set() # Keep references to background tasks
474
+ # Backwards-compatibility maps: which context_id uses which (server_key, session_id)
475
+ self._context_to_session: dict[str, tuple[str, str]] = {}
476
+ # Reference count for each active (server_key, session_id)
477
+ self._session_refcount: dict[tuple[str, str], int] = {}
478
+ # Cache which transport works for each server to avoid retrying failed transports
479
+ # server_key -> "streamable_http" | "sse"
480
+ self._transport_preference: dict[str, str] = {}
481
+ self._cleanup_task = None
482
+ self._start_cleanup_task()
483
+
484
+ def _start_cleanup_task(self):
485
+ """Start the periodic cleanup task."""
486
+ if self._cleanup_task is None or self._cleanup_task.done():
487
+ self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
488
+ self._background_tasks.add(self._cleanup_task)
489
+ self._cleanup_task.add_done_callback(self._background_tasks.discard)
490
+
491
+ async def _periodic_cleanup(self):
492
+ """Periodically clean up idle sessions."""
493
+ while True:
494
+ try:
495
+ await asyncio.sleep(get_session_cleanup_interval())
496
+ await self._cleanup_idle_sessions()
497
+ except asyncio.CancelledError:
498
+ break
499
+ except (RuntimeError, KeyError, ClosedResourceError, ValueError, asyncio.TimeoutError) as e:
500
+ # Handle common recoverable errors without stopping the cleanup loop
501
+ await logger.awarning(f"Error in periodic cleanup: {e}")
502
+
503
+ async def _cleanup_idle_sessions(self):
504
+ """Clean up sessions that have been idle for too long."""
505
+ current_time = asyncio.get_event_loop().time()
506
+ servers_to_remove = []
507
+
508
+ for server_key, server_data in self.sessions_by_server.items():
509
+ sessions = server_data.get("sessions", {})
510
+ sessions_to_remove = []
511
+
512
+ for session_id, session_info in list(sessions.items()):
513
+ if current_time - session_info["last_used"] > get_session_idle_timeout():
514
+ sessions_to_remove.append(session_id)
515
+
516
+ # Clean up idle sessions
517
+ for session_id in sessions_to_remove:
518
+ await logger.ainfo(f"Cleaning up idle session {session_id} for server {server_key}")
519
+ await self._cleanup_session_by_id(server_key, session_id)
520
+
521
+ # Remove server entry if no sessions left
522
+ if not sessions:
523
+ servers_to_remove.append(server_key)
524
+
525
+ # Clean up empty server entries
526
+ for server_key in servers_to_remove:
527
+ del self.sessions_by_server[server_key]
528
+
529
+ def _get_server_key(self, connection_params, transport_type: str) -> str:
530
+ """Generate a consistent server key based on connection parameters."""
531
+ if transport_type == "stdio":
532
+ if hasattr(connection_params, "command"):
533
+ # Include command, args, and environment for uniqueness
534
+ command_str = f"{connection_params.command} {' '.join(connection_params.args or [])}"
535
+ env_str = str(sorted((connection_params.env or {}).items()))
536
+ key_input = f"{command_str}|{env_str}"
537
+ return f"stdio_{hash(key_input)}"
538
+ elif transport_type == "streamable_http" and (
539
+ isinstance(connection_params, dict) and "url" in connection_params
540
+ ):
541
+ # Include URL and headers for uniqueness
542
+ url = connection_params["url"]
543
+ headers = str(sorted((connection_params.get("headers", {})).items()))
544
+ key_input = f"{url}|{headers}"
545
+ return f"streamable_http_{hash(key_input)}"
546
+
547
+ # Fallback to a generic key
548
+ return f"{transport_type}_{hash(str(connection_params))}"
549
+
550
+ async def _validate_session_connectivity(self, session) -> bool:
551
+ """Validate that the session is actually usable by testing a simple operation."""
552
+ try:
553
+ # Try to list tools as a connectivity test (this is a lightweight operation)
554
+ # Use a shorter timeout for the connectivity test to fail fast
555
+ response = await asyncio.wait_for(session.list_tools(), timeout=3.0)
556
+ except (asyncio.TimeoutError, ConnectionError, OSError, ValueError) as e:
557
+ await logger.adebug(f"Session connectivity test failed (standard error): {e}")
558
+ return False
559
+ except Exception as e:
560
+ # Handle MCP-specific errors that might not be in the standard list
561
+ error_str = str(e)
562
+ if (
563
+ "ClosedResourceError" in str(type(e))
564
+ or "Connection closed" in error_str
565
+ or "Connection lost" in error_str
566
+ or "Connection failed" in error_str
567
+ or "Transport closed" in error_str
568
+ or "Stream closed" in error_str
569
+ ):
570
+ await logger.adebug(f"Session connectivity test failed (MCP connection error): {e}")
571
+ return False
572
+ # Re-raise unexpected errors
573
+ await logger.awarning(f"Unexpected error in connectivity test: {e}")
574
+ raise
575
+ else:
576
+ # Validate that we got a meaningful response
577
+ if response is None:
578
+ await logger.adebug("Session connectivity test failed: received None response")
579
+ return False
580
+ try:
581
+ # Check if we can access the tools list (even if empty)
582
+ tools = getattr(response, "tools", None)
583
+ if tools is None:
584
+ await logger.adebug("Session connectivity test failed: no tools attribute in response")
585
+ return False
586
+ except (AttributeError, TypeError) as e:
587
+ await logger.adebug(f"Session connectivity test failed while validating response: {e}")
588
+ return False
589
+ else:
590
+ await logger.adebug(f"Session connectivity test passed: found {len(tools)} tools")
591
+ return True
592
+
593
+ async def get_session(self, context_id: str, connection_params, transport_type: str):
594
+ """Get or create a session with improved reuse strategy.
595
+
596
+ The key insight is that we should reuse sessions based on the server
597
+ identity (command + args for stdio, URL for Streamable HTTP) rather than the context_id.
598
+ This prevents creating a new subprocess for each unique context.
599
+ """
600
+ server_key = self._get_server_key(connection_params, transport_type)
601
+
602
+ # Ensure server entry exists
603
+ if server_key not in self.sessions_by_server:
604
+ self.sessions_by_server[server_key] = {"sessions": {}, "last_cleanup": asyncio.get_event_loop().time()}
605
+
606
+ server_data = self.sessions_by_server[server_key]
607
+ sessions = server_data["sessions"]
608
+
609
+ # Try to find a healthy existing session
610
+ for session_id, session_info in list(sessions.items()):
611
+ session = session_info["session"]
612
+ task = session_info["task"]
613
+
614
+ # Check if session is still alive
615
+ if not task.done():
616
+ # Update last used time
617
+ session_info["last_used"] = asyncio.get_event_loop().time()
618
+
619
+ # Quick health check
620
+ if await self._validate_session_connectivity(session):
621
+ await logger.adebug(f"Reusing existing session {session_id} for server {server_key}")
622
+ # record mapping & bump ref-count for backwards compatibility
623
+ self._context_to_session[context_id] = (server_key, session_id)
624
+ self._session_refcount[(server_key, session_id)] = (
625
+ self._session_refcount.get((server_key, session_id), 0) + 1
626
+ )
627
+ return session
628
+ await logger.ainfo(f"Session {session_id} for server {server_key} failed health check, cleaning up")
629
+ await self._cleanup_session_by_id(server_key, session_id)
630
+ else:
631
+ # Task is done, clean up
632
+ await logger.ainfo(f"Session {session_id} for server {server_key} task is done, cleaning up")
633
+ await self._cleanup_session_by_id(server_key, session_id)
634
+
635
+ # Check if we've reached the maximum number of sessions for this server
636
+ if len(sessions) >= get_max_sessions_per_server():
637
+ # Remove the oldest session
638
+ oldest_session_id = min(sessions.keys(), key=lambda x: sessions[x]["last_used"])
639
+ await logger.ainfo(
640
+ f"Maximum sessions reached for server {server_key}, removing oldest session {oldest_session_id}"
641
+ )
642
+ await self._cleanup_session_by_id(server_key, oldest_session_id)
643
+
644
+ # Create new session
645
+ session_id = f"{server_key}_{len(sessions)}"
646
+ await logger.ainfo(f"Creating new session {session_id} for server {server_key}")
647
+
648
+ if transport_type == "stdio":
649
+ session, task = await self._create_stdio_session(session_id, connection_params)
650
+ actual_transport = "stdio"
651
+ elif transport_type == "streamable_http":
652
+ # Pass the cached transport preference if available
653
+ preferred_transport = self._transport_preference.get(server_key)
654
+ session, task, actual_transport = await self._create_streamable_http_session(
655
+ session_id, connection_params, preferred_transport
656
+ )
657
+ # Cache the transport that worked for future connections
658
+ self._transport_preference[server_key] = actual_transport
659
+ else:
660
+ msg = f"Unknown transport type: {transport_type}"
661
+ raise ValueError(msg)
662
+
663
+ # Store session info with the actual transport used
664
+ sessions[session_id] = {
665
+ "session": session,
666
+ "task": task,
667
+ "type": actual_transport,
668
+ "last_used": asyncio.get_event_loop().time(),
669
+ }
670
+
671
+ # register mapping & initial ref-count for the new session
672
+ self._context_to_session[context_id] = (server_key, session_id)
673
+ self._session_refcount[(server_key, session_id)] = 1
674
+
675
+ return session
676
+
677
+ async def _create_stdio_session(self, session_id: str, connection_params):
678
+ """Create a new stdio session as a background task to avoid context issues."""
679
+ import asyncio
680
+
681
+ from mcp.client.stdio import stdio_client
682
+
683
+ # Create a future to get the session
684
+ session_future: asyncio.Future[ClientSession] = asyncio.Future()
685
+
686
+ async def session_task():
687
+ """Background task that keeps the session alive."""
688
+ try:
689
+ async with stdio_client(connection_params) as (read, write):
690
+ session = ClientSession(read, write)
691
+ async with session:
692
+ await session.initialize()
693
+ # Signal that session is ready
694
+ session_future.set_result(session)
695
+
696
+ # Keep the session alive until cancelled
697
+ import anyio
698
+
699
+ event = anyio.Event()
700
+ try:
701
+ await event.wait()
702
+ except asyncio.CancelledError:
703
+ await logger.ainfo(f"Session {session_id} is shutting down")
704
+ except Exception as e: # noqa: BLE001
705
+ if not session_future.done():
706
+ session_future.set_exception(e)
707
+
708
+ # Start the background task
709
+ task = asyncio.create_task(session_task())
710
+ self._background_tasks.add(task)
711
+ task.add_done_callback(self._background_tasks.discard)
712
+
713
+ # Wait for session to be ready (use longer timeout for remote connections)
714
+ try:
715
+ session = await asyncio.wait_for(session_future, timeout=30.0)
716
+ except asyncio.TimeoutError as timeout_err:
717
+ # Clean up the failed task
718
+ if not task.done():
719
+ task.cancel()
720
+ import contextlib
721
+
722
+ with contextlib.suppress(asyncio.CancelledError):
723
+ await task
724
+ self._background_tasks.discard(task)
725
+ msg = f"Timeout waiting for STDIO session {session_id} to initialize"
726
+ await logger.aerror(msg)
727
+ raise ValueError(msg) from timeout_err
728
+
729
+ return session, task
730
+
731
+ async def _create_streamable_http_session(
732
+ self, session_id: str, connection_params, preferred_transport: str | None = None
733
+ ):
734
+ """Create a new Streamable HTTP session with SSE fallback as a background task to avoid context issues.
735
+
736
+ Args:
737
+ session_id: Unique identifier for this session
738
+ connection_params: Connection parameters including URL, headers, timeouts, verify_ssl
739
+ preferred_transport: If set to "sse", skip Streamable HTTP and go directly to SSE
740
+
741
+ Returns:
742
+ tuple: (session, task, transport_used) where transport_used is "streamable_http" or "sse"
743
+ """
744
+ import asyncio
745
+
746
+ from mcp.client.sse import sse_client
747
+ from mcp.client.streamable_http import streamablehttp_client
748
+
749
+ # Create a future to get the session
750
+ session_future: asyncio.Future[ClientSession] = asyncio.Future()
751
+ # Track which transport succeeded
752
+ used_transport: list[str] = []
753
+
754
+ # Get verify_ssl option from connection params, default to True
755
+ verify_ssl = connection_params.get("verify_ssl", True)
756
+
757
+ # Create custom httpx client factory with SSL verification option
758
+ def custom_httpx_factory(
759
+ headers: dict[str, str] | None = None,
760
+ timeout: httpx.Timeout | None = None,
761
+ auth: httpx.Auth | None = None,
762
+ ) -> httpx.AsyncClient:
763
+ return create_mcp_http_client_with_ssl_option(
764
+ headers=headers, timeout=timeout, auth=auth, verify_ssl=verify_ssl
765
+ )
766
+
767
+ async def session_task():
768
+ """Background task that keeps the session alive."""
769
+ streamable_error = None
770
+
771
+ # Skip Streamable HTTP if we know SSE works for this server
772
+ if preferred_transport != "sse":
773
+ # Try Streamable HTTP first with a quick timeout
774
+ try:
775
+ await logger.adebug(f"Attempting Streamable HTTP connection for session {session_id}")
776
+ # Use a shorter timeout for the initial connection attempt (2 seconds)
777
+ async with streamablehttp_client(
778
+ url=connection_params["url"],
779
+ headers=connection_params["headers"],
780
+ timeout=connection_params["timeout_seconds"],
781
+ httpx_client_factory=custom_httpx_factory,
782
+ ) as (read, write, _):
783
+ session = ClientSession(read, write)
784
+ async with session:
785
+ # Initialize with a timeout to fail fast
786
+ await asyncio.wait_for(session.initialize(), timeout=2.0)
787
+ used_transport.append("streamable_http")
788
+ await logger.ainfo(f"Session {session_id} connected via Streamable HTTP")
789
+ # Signal that session is ready
790
+ session_future.set_result(session)
791
+
792
+ # Keep the session alive until cancelled
793
+ import anyio
794
+
795
+ event = anyio.Event()
796
+ try:
797
+ await event.wait()
798
+ except asyncio.CancelledError:
799
+ await logger.ainfo(f"Session {session_id} (Streamable HTTP) is shutting down")
800
+ except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001
801
+ # If Streamable HTTP fails or times out, try SSE as fallback immediately
802
+ streamable_error = e
803
+ error_type = "timed out" if isinstance(e, asyncio.TimeoutError) else "failed"
804
+ await logger.awarning(
805
+ f"Streamable HTTP {error_type} for session {session_id}: {e}. Falling back to SSE..."
806
+ )
807
+ else:
808
+ await logger.adebug(f"Skipping Streamable HTTP for session {session_id}, using cached SSE preference")
809
+
810
+ # Try SSE if Streamable HTTP failed or if SSE is preferred
811
+ if streamable_error is not None or preferred_transport == "sse":
812
+ try:
813
+ await logger.adebug(f"Attempting SSE connection for session {session_id}")
814
+ # Extract SSE read timeout from connection params, default to 30s if not present
815
+ sse_read_timeout = connection_params.get("sse_read_timeout_seconds", 30)
816
+
817
+ async with sse_client(
818
+ connection_params["url"],
819
+ connection_params["headers"],
820
+ connection_params["timeout_seconds"],
821
+ sse_read_timeout,
822
+ httpx_client_factory=custom_httpx_factory,
823
+ ) as (read, write):
824
+ session = ClientSession(read, write)
825
+ async with session:
826
+ await session.initialize()
827
+ used_transport.append("sse")
828
+ fallback_msg = " (fallback)" if streamable_error else " (preferred)"
829
+ await logger.ainfo(f"Session {session_id} connected via SSE{fallback_msg}")
830
+ # Signal that session is ready
831
+ if not session_future.done():
832
+ session_future.set_result(session)
833
+
834
+ # Keep the session alive until cancelled
835
+ import anyio
836
+
837
+ event = anyio.Event()
838
+ try:
839
+ await event.wait()
840
+ except asyncio.CancelledError:
841
+ await logger.ainfo(f"Session {session_id} (SSE) is shutting down")
842
+ except Exception as sse_error: # noqa: BLE001
843
+ # Both transports failed (or just SSE if it was preferred)
844
+ if streamable_error:
845
+ await logger.aerror(
846
+ f"Both Streamable HTTP and SSE failed for session {session_id}. "
847
+ f"Streamable HTTP error: {streamable_error}. SSE error: {sse_error}"
848
+ )
849
+ if not session_future.done():
850
+ session_future.set_exception(
851
+ ValueError(
852
+ f"Failed to connect via Streamable HTTP ({streamable_error}) or SSE ({sse_error})"
853
+ )
854
+ )
855
+ else:
856
+ await logger.aerror(f"SSE connection failed for session {session_id}: {sse_error}")
857
+ if not session_future.done():
858
+ session_future.set_exception(ValueError(f"Failed to connect via SSE: {sse_error}"))
859
+
860
+ # Start the background task
861
+ task = asyncio.create_task(session_task())
862
+ self._background_tasks.add(task)
863
+ task.add_done_callback(self._background_tasks.discard)
864
+
865
+ # Wait for session to be ready (use longer timeout for remote connections)
866
+ try:
867
+ session = await asyncio.wait_for(session_future, timeout=30.0)
868
+ # Log which transport was used
869
+ if used_transport:
870
+ transport_used = used_transport[0]
871
+ await logger.ainfo(f"Session {session_id} successfully established using {transport_used}")
872
+ return session, task, transport_used
873
+ # This shouldn't happen, but handle it just in case
874
+ msg = f"Session {session_id} established but transport not recorded"
875
+ raise ValueError(msg)
876
+ except asyncio.TimeoutError as timeout_err:
877
+ # Clean up the failed task
878
+ if not task.done():
879
+ task.cancel()
880
+ import contextlib
881
+
882
+ with contextlib.suppress(asyncio.CancelledError):
883
+ await task
884
+ self._background_tasks.discard(task)
885
+ msg = f"Timeout waiting for Streamable HTTP/SSE session {session_id} to initialize"
886
+ await logger.aerror(msg)
887
+ raise ValueError(msg) from timeout_err
888
+
889
+ async def _cleanup_session_by_id(self, server_key: str, session_id: str):
890
+ """Clean up a specific session by server key and session ID."""
891
+ if server_key not in self.sessions_by_server:
892
+ return
893
+
894
+ server_data = self.sessions_by_server[server_key]
895
+ # Handle both old and new session structure
896
+ if isinstance(server_data, dict) and "sessions" in server_data:
897
+ sessions = server_data["sessions"]
898
+ else:
899
+ # Handle old structure where sessions were stored directly
900
+ sessions = server_data
901
+
902
+ if session_id not in sessions:
903
+ return
904
+
905
+ session_info = sessions[session_id]
906
+ try:
907
+ # First try to properly close the session if it exists
908
+ if "session" in session_info:
909
+ session = session_info["session"]
910
+
911
+ # Try async close first (aclose method)
912
+ if hasattr(session, "aclose"):
913
+ try:
914
+ await session.aclose()
915
+ await logger.adebug("Successfully closed session %s using aclose()", session_id)
916
+ except Exception as e: # noqa: BLE001
917
+ await logger.adebug("Error closing session %s with aclose(): %s", session_id, e)
918
+
919
+ # If no aclose, try regular close method
920
+ elif hasattr(session, "close"):
921
+ try:
922
+ # Check if close() is awaitable using inspection
923
+ if inspect.iscoroutinefunction(session.close):
924
+ # It's an async method
925
+ await session.close()
926
+ await logger.adebug("Successfully closed session %s using async close()", session_id)
927
+ else:
928
+ # Try calling it and check if result is awaitable
929
+ close_result = session.close()
930
+ if inspect.isawaitable(close_result):
931
+ await close_result
932
+ await logger.adebug(
933
+ "Successfully closed session %s using awaitable close()", session_id
934
+ )
935
+ else:
936
+ # It's a synchronous close
937
+ await logger.adebug("Successfully closed session %s using sync close()", session_id)
938
+ except Exception as e: # noqa: BLE001
939
+ await logger.adebug("Error closing session %s with close(): %s", session_id, e)
940
+
941
+ # Cancel the background task which will properly close the session
942
+ if "task" in session_info:
943
+ task = session_info["task"]
944
+ if not task.done():
945
+ task.cancel()
946
+ try:
947
+ await task
948
+ except asyncio.CancelledError:
949
+ await logger.ainfo(f"Cancelled task for session {session_id}")
950
+ except Exception as e: # noqa: BLE001
951
+ await logger.awarning(f"Error cleaning up session {session_id}: {e}")
952
+ finally:
953
+ # Remove from sessions dict
954
+ del sessions[session_id]
955
+
956
+ async def cleanup_all(self):
957
+ """Clean up all sessions."""
958
+ # Cancel periodic cleanup task
959
+ if self._cleanup_task and not self._cleanup_task.done():
960
+ self._cleanup_task.cancel()
961
+ with contextlib.suppress(asyncio.CancelledError):
962
+ await self._cleanup_task
963
+
964
+ # Clean up all sessions
965
+ for server_key in list(self.sessions_by_server.keys()):
966
+ server_data = self.sessions_by_server[server_key]
967
+ # Handle both old and new session structure
968
+ if isinstance(server_data, dict) and "sessions" in server_data:
969
+ sessions = server_data["sessions"]
970
+ else:
971
+ # Handle old structure where sessions were stored directly
972
+ sessions = server_data
973
+
974
+ for session_id in list(sessions.keys()):
975
+ await self._cleanup_session_by_id(server_key, session_id)
976
+
977
+ # Clear the sessions_by_server structure completely
978
+ self.sessions_by_server.clear()
979
+
980
+ # Clear compatibility maps
981
+ self._context_to_session.clear()
982
+ self._session_refcount.clear()
983
+
984
+ # Clear all background tasks
985
+ for task in list(self._background_tasks):
986
+ if not task.done():
987
+ task.cancel()
988
+ with contextlib.suppress(asyncio.CancelledError):
989
+ await task
990
+
991
+ # Give a bit more time for subprocess transports to clean up
992
+ # This helps prevent the BaseSubprocessTransport.__del__ warnings
993
+ await asyncio.sleep(0.5)
994
+
995
+ async def _cleanup_session(self, context_id: str):
996
+ """Backward-compat cleanup by context_id.
997
+
998
+ Decrements the ref-count for the session used by *context_id* and only
999
+ tears the session down when the last context that references it goes
1000
+ away.
1001
+ """
1002
+ mapping = self._context_to_session.get(context_id)
1003
+ if not mapping:
1004
+ await logger.adebug(f"No session mapping found for context_id {context_id}")
1005
+ return
1006
+
1007
+ server_key, session_id = mapping
1008
+ ref_key = (server_key, session_id)
1009
+ remaining = self._session_refcount.get(ref_key, 1) - 1
1010
+
1011
+ if remaining <= 0:
1012
+ await self._cleanup_session_by_id(server_key, session_id)
1013
+ self._session_refcount.pop(ref_key, None)
1014
+ else:
1015
+ self._session_refcount[ref_key] = remaining
1016
+
1017
+ # Remove the mapping for this context
1018
+ self._context_to_session.pop(context_id, None)
1019
+
1020
+
1021
+ class MCPStdioClient:
1022
+ def __init__(self, component_cache=None):
1023
+ self.session: ClientSession | None = None
1024
+ self._connection_params = None
1025
+ self._connected = False
1026
+ self._session_context: str | None = None
1027
+ self._component_cache = component_cache
1028
+
1029
+ async def _connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
1030
+ """Connect to MCP server using stdio transport (SDK style)."""
1031
+ from mcp import StdioServerParameters
1032
+
1033
+ command = command_str.split(" ")
1034
+ env_data: dict[str, str] = {"DEBUG": "true", "PATH": os.environ["PATH"], **(env or {})}
1035
+
1036
+ if platform.system() == "Windows":
1037
+ server_params = StdioServerParameters(
1038
+ command="cmd",
1039
+ args=[
1040
+ "/c",
1041
+ f"{command[0]} {' '.join(command[1:])} || echo Command failed with exit code %errorlevel% 1>&2",
1042
+ ],
1043
+ env=env_data,
1044
+ )
1045
+ else:
1046
+ server_params = StdioServerParameters(
1047
+ command="bash",
1048
+ args=["-c", f"exec {command_str} || echo 'Command failed with exit code $?' >&2"],
1049
+ env=env_data,
1050
+ )
1051
+
1052
+ # Store connection parameters for later use in run_tool
1053
+ self._connection_params = server_params
1054
+
1055
+ # If no session context is set, create a default one
1056
+ if not self._session_context:
1057
+ # Generate a fallback context based on connection parameters
1058
+ import uuid
1059
+
1060
+ param_hash = uuid.uuid4().hex[:8]
1061
+ self._session_context = f"default_{param_hash}"
1062
+
1063
+ # Get or create a persistent session
1064
+ session = await self._get_or_create_session()
1065
+ response = await session.list_tools()
1066
+ self._connected = True
1067
+ return response.tools
1068
+
1069
+ async def connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
1070
+ """Connect to MCP server using stdio transport (SDK style)."""
1071
+ return await asyncio.wait_for(
1072
+ self._connect_to_server(command_str, env), timeout=get_settings_service().settings.mcp_server_timeout
1073
+ )
1074
+
1075
+ def set_session_context(self, context_id: str):
1076
+ """Set the session context (e.g., flow_id + user_id + session_id)."""
1077
+ self._session_context = context_id
1078
+
1079
+ def _get_session_manager(self) -> MCPSessionManager:
1080
+ """Get or create session manager from component cache."""
1081
+ if not self._component_cache:
1082
+ # Fallback to instance-level session manager if no cache
1083
+ if not hasattr(self, "_session_manager"):
1084
+ self._session_manager = MCPSessionManager()
1085
+ return self._session_manager
1086
+
1087
+ from lfx.services.cache.utils import CacheMiss
1088
+
1089
+ session_manager = self._component_cache.get("mcp_session_manager")
1090
+ if isinstance(session_manager, CacheMiss):
1091
+ session_manager = MCPSessionManager()
1092
+ self._component_cache.set("mcp_session_manager", session_manager)
1093
+ return session_manager
1094
+
1095
+ async def _get_or_create_session(self) -> ClientSession:
1096
+ """Get or create a persistent session for the current context."""
1097
+ if not self._session_context or not self._connection_params:
1098
+ msg = "Session context and connection params must be set"
1099
+ raise ValueError(msg)
1100
+
1101
+ # Use cached session manager to get/create persistent session
1102
+ session_manager = self._get_session_manager()
1103
+ return await session_manager.get_session(self._session_context, self._connection_params, "stdio")
1104
+
1105
+ async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
1106
+ """Run a tool with the given arguments using context-specific session.
1107
+
1108
+ Args:
1109
+ tool_name: Name of the tool to run
1110
+ arguments: Dictionary of arguments to pass to the tool
1111
+
1112
+ Returns:
1113
+ The result of the tool execution
1114
+
1115
+ Raises:
1116
+ ValueError: If session is not initialized or tool execution fails
1117
+ """
1118
+ if not self._connected or not self._connection_params:
1119
+ msg = "Session not initialized or disconnected. Call connect_to_server first."
1120
+ raise ValueError(msg)
1121
+
1122
+ # If no session context is set, create a default one
1123
+ if not self._session_context:
1124
+ # Generate a fallback context based on connection parameters
1125
+ import uuid
1126
+
1127
+ param_hash = uuid.uuid4().hex[:8]
1128
+ self._session_context = f"default_{param_hash}"
1129
+
1130
+ max_retries = 2
1131
+ last_error_type = None
1132
+
1133
+ for attempt in range(max_retries):
1134
+ try:
1135
+ await logger.adebug(f"Attempting to run tool '{tool_name}' (attempt {attempt + 1}/{max_retries})")
1136
+ # Get or create persistent session
1137
+ session = await self._get_or_create_session()
1138
+
1139
+ result = await asyncio.wait_for(
1140
+ session.call_tool(tool_name, arguments=arguments),
1141
+ timeout=30.0, # 30 second timeout
1142
+ )
1143
+ except Exception as e:
1144
+ current_error_type = type(e).__name__
1145
+ await logger.awarning(f"Tool '{tool_name}' failed on attempt {attempt + 1}: {current_error_type} - {e}")
1146
+
1147
+ # Import specific MCP error types for detection
1148
+ try:
1149
+ is_closed_resource_error = isinstance(e, ClosedResourceError)
1150
+ is_mcp_connection_error = isinstance(e, McpError) and "Connection closed" in str(e)
1151
+ except ImportError:
1152
+ is_closed_resource_error = "ClosedResourceError" in str(type(e))
1153
+ is_mcp_connection_error = "Connection closed" in str(e)
1154
+
1155
+ # Detect timeout errors
1156
+ is_timeout_error = isinstance(e, asyncio.TimeoutError | TimeoutError)
1157
+
1158
+ # If we're getting the same error type repeatedly, don't retry
1159
+ if last_error_type == current_error_type and attempt > 0:
1160
+ await logger.aerror(f"Repeated {current_error_type} error for tool '{tool_name}', not retrying")
1161
+ break
1162
+
1163
+ last_error_type = current_error_type
1164
+
1165
+ # If it's a connection error (ClosedResourceError or MCP connection closed) and we have retries left
1166
+ if (is_closed_resource_error or is_mcp_connection_error) and attempt < max_retries - 1:
1167
+ await logger.awarning(
1168
+ f"MCP session connection issue for tool '{tool_name}', retrying with fresh session..."
1169
+ )
1170
+ # Clean up the dead session
1171
+ if self._session_context:
1172
+ session_manager = self._get_session_manager()
1173
+ await session_manager._cleanup_session(self._session_context)
1174
+ # Add a small delay before retry
1175
+ await asyncio.sleep(0.5)
1176
+ continue
1177
+
1178
+ # If it's a timeout error and we have retries left, try once more
1179
+ if is_timeout_error and attempt < max_retries - 1:
1180
+ await logger.awarning(f"Tool '{tool_name}' timed out, retrying...")
1181
+ # Don't clean up session for timeouts, might just be a slow response
1182
+ await asyncio.sleep(1.0)
1183
+ continue
1184
+
1185
+ # For other errors or no retries left, handle as before
1186
+ if (
1187
+ isinstance(e, ConnectionError | TimeoutError | OSError | ValueError)
1188
+ or is_closed_resource_error
1189
+ or is_mcp_connection_error
1190
+ or is_timeout_error
1191
+ ):
1192
+ msg = f"Failed to run tool '{tool_name}' after {attempt + 1} attempts: {e}"
1193
+ await logger.aerror(msg)
1194
+ # Clean up failed session from cache
1195
+ if self._session_context and self._component_cache:
1196
+ cache_key = f"mcp_session_stdio_{self._session_context}"
1197
+ self._component_cache.delete(cache_key)
1198
+ self._connected = False
1199
+ raise ValueError(msg) from e
1200
+ # Re-raise unexpected errors
1201
+ raise
1202
+ else:
1203
+ await logger.adebug(f"Tool '{tool_name}' completed successfully")
1204
+ return result
1205
+
1206
+ # This should never be reached due to the exception handling above
1207
+ msg = f"Failed to run tool '{tool_name}': Maximum retries exceeded with repeated {last_error_type} errors"
1208
+ await logger.aerror(msg)
1209
+ raise ValueError(msg)
1210
+
1211
+ async def disconnect(self):
1212
+ """Properly close the connection and clean up resources."""
1213
+ # For stdio transport, there is no remote session to terminate explicitly
1214
+ # The session cleanup happens when the background task is cancelled
1215
+
1216
+ # Clean up local session using the session manager
1217
+ if self._session_context:
1218
+ session_manager = self._get_session_manager()
1219
+ await session_manager._cleanup_session(self._session_context)
1220
+
1221
+ # Reset local state
1222
+ self.session = None
1223
+ self._connection_params = None
1224
+ self._connected = False
1225
+ self._session_context = None
1226
+
1227
+ async def __aenter__(self):
1228
+ return self
1229
+
1230
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
1231
+ await self.disconnect()
1232
+
1233
+
1234
+ class MCPStreamableHttpClient:
1235
+ def __init__(self, component_cache=None):
1236
+ self.session: ClientSession | None = None
1237
+ self._connection_params = None
1238
+ self._connected = False
1239
+ self._session_context: str | None = None
1240
+ self._component_cache = component_cache
1241
+
1242
+ def _get_session_manager(self) -> MCPSessionManager:
1243
+ """Get or create session manager from component cache."""
1244
+ if not self._component_cache:
1245
+ # Fallback to instance-level session manager if no cache
1246
+ if not hasattr(self, "_session_manager"):
1247
+ self._session_manager = MCPSessionManager()
1248
+ return self._session_manager
1249
+
1250
+ from lfx.services.cache.utils import CacheMiss
1251
+
1252
+ session_manager = self._component_cache.get("mcp_session_manager")
1253
+ if isinstance(session_manager, CacheMiss):
1254
+ session_manager = MCPSessionManager()
1255
+ self._component_cache.set("mcp_session_manager", session_manager)
1256
+ return session_manager
1257
+
1258
+ async def validate_url(self, url: str | None) -> tuple[bool, str]:
1259
+ """Validate the Streamable HTTP URL before attempting connection."""
1260
+ try:
1261
+ parsed = urlparse(url)
1262
+ if not parsed.scheme or not parsed.netloc:
1263
+ return False, "Invalid URL format. Must include scheme (http/https) and host."
1264
+ except (ValueError, OSError) as e:
1265
+ return False, f"URL validation error: {e!s}"
1266
+ return True, ""
1267
+
1268
+ async def _connect_to_server(
1269
+ self,
1270
+ url: str | None,
1271
+ headers: dict[str, str] | None = None,
1272
+ timeout_seconds: int = 30,
1273
+ sse_read_timeout_seconds: int = 30,
1274
+ *,
1275
+ verify_ssl: bool = True,
1276
+ ) -> list[StructuredTool]:
1277
+ """Connect to MCP server using Streamable HTTP transport with SSE fallback (SDK style)."""
1278
+ # Validate and sanitize headers early
1279
+ validated_headers = _process_headers(headers)
1280
+
1281
+ if url is None:
1282
+ msg = "URL is required for StreamableHTTP or SSE mode"
1283
+ raise ValueError(msg)
1284
+
1285
+ # Only validate URL if we don't have a cached session
1286
+ # This avoids expensive HTTP validation calls when reusing sessions
1287
+ if not self._connected or not self._connection_params:
1288
+ is_valid, error_msg = await self.validate_url(url)
1289
+ if not is_valid:
1290
+ msg = f"Invalid Streamable HTTP or SSE URL ({url}): {error_msg}"
1291
+ raise ValueError(msg)
1292
+ # Store connection parameters for later use in run_tool
1293
+ # Include SSE read timeout for fallback and SSL verification option
1294
+ self._connection_params = {
1295
+ "url": url,
1296
+ "headers": validated_headers,
1297
+ "timeout_seconds": timeout_seconds,
1298
+ "sse_read_timeout_seconds": sse_read_timeout_seconds,
1299
+ "verify_ssl": verify_ssl,
1300
+ }
1301
+ elif headers:
1302
+ self._connection_params["headers"] = validated_headers
1303
+
1304
+ # If no session context is set, create a default one
1305
+ if not self._session_context:
1306
+ # Generate a fallback context based on connection parameters
1307
+ import uuid
1308
+
1309
+ param_hash = uuid.uuid4().hex[:8]
1310
+ self._session_context = f"default_http_{param_hash}"
1311
+
1312
+ # Get or create a persistent session (will try Streamable HTTP, then SSE fallback)
1313
+ session = await self._get_or_create_session()
1314
+ response = await session.list_tools()
1315
+ self._connected = True
1316
+ return response.tools
1317
+
1318
+ async def connect_to_server(
1319
+ self,
1320
+ url: str,
1321
+ headers: dict[str, str] | None = None,
1322
+ sse_read_timeout_seconds: int = 30,
1323
+ *,
1324
+ verify_ssl: bool = True,
1325
+ ) -> list[StructuredTool]:
1326
+ """Connect to MCP server using Streamable HTTP with SSE fallback transport (SDK style)."""
1327
+ return await asyncio.wait_for(
1328
+ self._connect_to_server(
1329
+ url, headers, sse_read_timeout_seconds=sse_read_timeout_seconds, verify_ssl=verify_ssl
1330
+ ),
1331
+ timeout=get_settings_service().settings.mcp_server_timeout,
1332
+ )
1333
+
1334
+ def set_session_context(self, context_id: str):
1335
+ """Set the session context (e.g., flow_id + user_id + session_id)."""
1336
+ self._session_context = context_id
1337
+
1338
+ async def _get_or_create_session(self) -> ClientSession:
1339
+ """Get or create a persistent session for the current context."""
1340
+ if not self._session_context or not self._connection_params:
1341
+ msg = "Session context and params must be set"
1342
+ raise ValueError(msg)
1343
+
1344
+ # Use cached session manager to get/create persistent session
1345
+ session_manager = self._get_session_manager()
1346
+ # Cache session so we can access server-assigned session_id later for DELETE
1347
+ self.session = await session_manager.get_session(
1348
+ self._session_context, self._connection_params, "streamable_http"
1349
+ )
1350
+ return self.session
1351
+
1352
+ async def _terminate_remote_session(self) -> None:
1353
+ """Attempt to explicitly terminate the remote MCP session via HTTP DELETE (best-effort)."""
1354
+ # Only relevant for Streamable HTTP or SSE transport
1355
+ if not self._connection_params or "url" not in self._connection_params:
1356
+ return
1357
+
1358
+ url: str = self._connection_params["url"]
1359
+
1360
+ # Retrieve session id from the underlying SDK if exposed
1361
+ session_id = None
1362
+ if getattr(self, "session", None) is not None:
1363
+ # Common attributes in MCP python SDK: `session_id` or `id`
1364
+ session_id = getattr(self.session, "session_id", None) or getattr(self.session, "id", None)
1365
+
1366
+ headers: dict[str, str] = dict(self._connection_params.get("headers", {}))
1367
+ if session_id:
1368
+ headers["Mcp-Session-Id"] = str(session_id)
1369
+
1370
+ try:
1371
+ async with httpx.AsyncClient(timeout=5.0) as client:
1372
+ await client.delete(url, headers=headers)
1373
+ except Exception as e: # noqa: BLE001
1374
+ # DELETE is advisory—log and continue
1375
+ logger.debug(f"Unable to send session DELETE to '{url}': {e}")
1376
+
1377
+ async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
1378
+ """Run a tool with the given arguments using context-specific session.
1379
+
1380
+ Args:
1381
+ tool_name: Name of the tool to run
1382
+ arguments: Dictionary of arguments to pass to the tool
1383
+
1384
+ Returns:
1385
+ The result of the tool execution
1386
+
1387
+ Raises:
1388
+ ValueError: If session is not initialized or tool execution fails
1389
+ """
1390
+ if not self._connected or not self._connection_params:
1391
+ msg = "Session not initialized or disconnected. Call connect_to_server first."
1392
+ raise ValueError(msg)
1393
+
1394
+ # If no session context is set, create a default one
1395
+ if not self._session_context:
1396
+ # Generate a fallback context based on connection parameters
1397
+ import uuid
1398
+
1399
+ param_hash = uuid.uuid4().hex[:8]
1400
+ self._session_context = f"default_http_{param_hash}"
1401
+
1402
+ max_retries = 2
1403
+ last_error_type = None
1404
+
1405
+ for attempt in range(max_retries):
1406
+ try:
1407
+ await logger.adebug(f"Attempting to run tool '{tool_name}' (attempt {attempt + 1}/{max_retries})")
1408
+ # Get or create persistent session
1409
+ session = await self._get_or_create_session()
1410
+
1411
+ result = await asyncio.wait_for(
1412
+ session.call_tool(tool_name, arguments=arguments),
1413
+ timeout=30.0, # 30 second timeout
1414
+ )
1415
+ except Exception as e:
1416
+ current_error_type = type(e).__name__
1417
+ await logger.awarning(f"Tool '{tool_name}' failed on attempt {attempt + 1}: {current_error_type} - {e}")
1418
+
1419
+ # Import specific MCP error types for detection
1420
+ try:
1421
+ from anyio import ClosedResourceError
1422
+ from mcp.shared.exceptions import McpError
1423
+
1424
+ is_closed_resource_error = isinstance(e, ClosedResourceError)
1425
+ is_mcp_connection_error = isinstance(e, McpError) and "Connection closed" in str(e)
1426
+ except ImportError:
1427
+ is_closed_resource_error = "ClosedResourceError" in str(type(e))
1428
+ is_mcp_connection_error = "Connection closed" in str(e)
1429
+
1430
+ # Detect timeout errors
1431
+ is_timeout_error = isinstance(e, asyncio.TimeoutError | TimeoutError)
1432
+
1433
+ # If we're getting the same error type repeatedly, don't retry
1434
+ if last_error_type == current_error_type and attempt > 0:
1435
+ await logger.aerror(f"Repeated {current_error_type} error for tool '{tool_name}', not retrying")
1436
+ break
1437
+
1438
+ last_error_type = current_error_type
1439
+
1440
+ # If it's a connection error (ClosedResourceError or MCP connection closed) and we have retries left
1441
+ if (is_closed_resource_error or is_mcp_connection_error) and attempt < max_retries - 1:
1442
+ await logger.awarning(
1443
+ f"MCP session connection issue for tool '{tool_name}', retrying with fresh session..."
1444
+ )
1445
+ # Clean up the dead session
1446
+ if self._session_context:
1447
+ session_manager = self._get_session_manager()
1448
+ await session_manager._cleanup_session(self._session_context)
1449
+ # Add a small delay before retry
1450
+ await asyncio.sleep(0.5)
1451
+ continue
1452
+
1453
+ # If it's a timeout error and we have retries left, try once more
1454
+ if is_timeout_error and attempt < max_retries - 1:
1455
+ await logger.awarning(f"Tool '{tool_name}' timed out, retrying...")
1456
+ # Don't clean up session for timeouts, might just be a slow response
1457
+ await asyncio.sleep(1.0)
1458
+ continue
1459
+
1460
+ # For other errors or no retries left, handle as before
1461
+ if (
1462
+ isinstance(e, ConnectionError | TimeoutError | OSError | ValueError)
1463
+ or is_closed_resource_error
1464
+ or is_mcp_connection_error
1465
+ or is_timeout_error
1466
+ ):
1467
+ msg = f"Failed to run tool '{tool_name}' after {attempt + 1} attempts: {e}"
1468
+ await logger.aerror(msg)
1469
+ # Clean up failed session from cache
1470
+ if self._session_context and self._component_cache:
1471
+ cache_key = f"mcp_session_http_{self._session_context}"
1472
+ self._component_cache.delete(cache_key)
1473
+ self._connected = False
1474
+ raise ValueError(msg) from e
1475
+ # Re-raise unexpected errors
1476
+ raise
1477
+ else:
1478
+ await logger.adebug(f"Tool '{tool_name}' completed successfully")
1479
+ return result
1480
+
1481
+ # This should never be reached due to the exception handling above
1482
+ msg = f"Failed to run tool '{tool_name}': Maximum retries exceeded with repeated {last_error_type} errors"
1483
+ await logger.aerror(msg)
1484
+ raise ValueError(msg)
1485
+
1486
+ async def disconnect(self):
1487
+ """Properly close the connection and clean up resources."""
1488
+ # Attempt best-effort remote session termination first
1489
+ await self._terminate_remote_session()
1490
+
1491
+ # Clean up local session using the session manager
1492
+ if self._session_context:
1493
+ session_manager = self._get_session_manager()
1494
+ await session_manager._cleanup_session(self._session_context)
1495
+
1496
+ # Reset local state
1497
+ self.session = None
1498
+ self._connection_params = None
1499
+ self._connected = False
1500
+ self._session_context = None
1501
+
1502
+ async def __aenter__(self):
1503
+ return self
1504
+
1505
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
1506
+ await self.disconnect()
1507
+
1508
+
1509
+ # Backward compatibility: MCPSseClient is now an alias for MCPStreamableHttpClient
1510
+ # The new client supports both Streamable HTTP and SSE with automatic fallback
1511
+ MCPSseClient = MCPStreamableHttpClient
1512
+
1513
+
1514
+ async def update_tools(
1515
+ server_name: str,
1516
+ server_config: dict,
1517
+ mcp_stdio_client: MCPStdioClient | None = None,
1518
+ mcp_streamable_http_client: MCPStreamableHttpClient | None = None,
1519
+ mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility
1520
+ ) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]:
1521
+ """Fetch server config and update available tools."""
1522
+ if server_config is None:
1523
+ server_config = {}
1524
+ if not server_name:
1525
+ return "", [], {}
1526
+ if mcp_stdio_client is None:
1527
+ mcp_stdio_client = MCPStdioClient()
1528
+
1529
+ # Backward compatibility: accept mcp_sse_client parameter
1530
+ if mcp_streamable_http_client is None:
1531
+ mcp_streamable_http_client = mcp_sse_client if mcp_sse_client is not None else MCPStreamableHttpClient()
1532
+
1533
+ # Fetch server config from backend
1534
+ # Determine mode from config, defaulting to Streamable_HTTP if URL present
1535
+ mode = server_config.get("mode", "")
1536
+ if not mode:
1537
+ mode = "Stdio" if "command" in server_config else "Streamable_HTTP" if "url" in server_config else ""
1538
+
1539
+ command = server_config.get("command", "")
1540
+ url = server_config.get("url", "")
1541
+ tools = []
1542
+ headers = _process_headers(server_config.get("headers", {}))
1543
+
1544
+ try:
1545
+ await _validate_connection_params(mode, command, url)
1546
+ except ValueError as e:
1547
+ logger.error(f"Invalid MCP server configuration for '{server_name}': {e}")
1548
+ raise
1549
+
1550
+ # Determine connection type and parameters
1551
+ client: MCPStdioClient | MCPStreamableHttpClient | None = None
1552
+ if mode == "Stdio":
1553
+ # Stdio connection
1554
+ args = server_config.get("args", [])
1555
+ env = server_config.get("env", {})
1556
+ full_command = " ".join([command, *args])
1557
+ tools = await mcp_stdio_client.connect_to_server(full_command, env)
1558
+ client = mcp_stdio_client
1559
+ elif mode in ["Streamable_HTTP", "SSE"]:
1560
+ # Streamable HTTP connection with SSE fallback
1561
+ verify_ssl = server_config.get("verify_ssl", True)
1562
+ tools = await mcp_streamable_http_client.connect_to_server(url, headers=headers, verify_ssl=verify_ssl)
1563
+ client = mcp_streamable_http_client
1564
+ else:
1565
+ logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
1566
+ return "", [], {}
1567
+
1568
+ if not tools or not client or not client._connected:
1569
+ logger.warning(f"No tools available from MCP server '{server_name}' or connection failed")
1570
+ return "", [], {}
1571
+
1572
+ tool_list = []
1573
+ tool_cache: dict[str, StructuredTool] = {}
1574
+ for tool in tools:
1575
+ if not tool or not hasattr(tool, "name"):
1576
+ continue
1577
+ try:
1578
+ args_schema = create_input_schema_from_json_schema(tool.inputSchema)
1579
+ if not args_schema:
1580
+ logger.warning(f"Could not create schema for tool '{tool.name}' from server '{server_name}'")
1581
+ continue
1582
+
1583
+ # Create a custom StructuredTool that bypasses schema validation
1584
+ class MCPStructuredTool(StructuredTool):
1585
+ def run(self, tool_input: str | dict, config=None, **kwargs):
1586
+ """Override the main run method to handle parameter conversion before validation."""
1587
+ # Parse tool_input if it's a string
1588
+ if isinstance(tool_input, str):
1589
+ try:
1590
+ parsed_input = json.loads(tool_input)
1591
+ except json.JSONDecodeError:
1592
+ parsed_input = {"input": tool_input}
1593
+ else:
1594
+ parsed_input = tool_input or {}
1595
+
1596
+ # Convert camelCase parameters to snake_case
1597
+ converted_input = self._convert_parameters(parsed_input)
1598
+
1599
+ # Call the parent run method with converted parameters
1600
+ return super().run(converted_input, config=config, **kwargs)
1601
+
1602
+ async def arun(self, tool_input: str | dict, config=None, **kwargs):
1603
+ """Override the main arun method to handle parameter conversion before validation."""
1604
+ # Parse tool_input if it's a string
1605
+ if isinstance(tool_input, str):
1606
+ try:
1607
+ parsed_input = json.loads(tool_input)
1608
+ except json.JSONDecodeError:
1609
+ parsed_input = {"input": tool_input}
1610
+ else:
1611
+ parsed_input = tool_input or {}
1612
+
1613
+ # Convert camelCase parameters to snake_case
1614
+ converted_input = self._convert_parameters(parsed_input)
1615
+
1616
+ # Call the parent arun method with converted parameters
1617
+ return await super().arun(converted_input, config=config, **kwargs)
1618
+
1619
+ def _convert_parameters(self, input_dict):
1620
+ if not input_dict or not isinstance(input_dict, dict):
1621
+ return input_dict
1622
+
1623
+ converted_dict = {}
1624
+ original_fields = set(self.args_schema.model_fields.keys())
1625
+
1626
+ for key, value in input_dict.items():
1627
+ if key in original_fields:
1628
+ # Field exists as-is
1629
+ converted_dict[key] = value
1630
+ else:
1631
+ # Try to convert camelCase to snake_case
1632
+ snake_key = _camel_to_snake(key)
1633
+ if snake_key in original_fields:
1634
+ converted_dict[snake_key] = value
1635
+ else:
1636
+ # Keep original key
1637
+ converted_dict[key] = value
1638
+
1639
+ return converted_dict
1640
+
1641
+ tool_obj = MCPStructuredTool(
1642
+ name=tool.name,
1643
+ description=tool.description or "",
1644
+ args_schema=args_schema,
1645
+ func=create_tool_func(tool.name, args_schema, client),
1646
+ coroutine=create_tool_coroutine(tool.name, args_schema, client),
1647
+ tags=[tool.name],
1648
+ metadata={"server_name": server_name},
1649
+ )
1650
+
1651
+ tool_list.append(tool_obj)
1652
+ tool_cache[tool.name] = tool_obj
1653
+ except (ConnectionError, TimeoutError, OSError, ValueError) as e:
1654
+ logger.error(f"Failed to create tool '{tool.name}' from server '{server_name}': {e}")
1655
+ msg = f"Failed to create tool '{tool.name}' from server '{server_name}': {e}"
1656
+ raise ValueError(msg) from e
1657
+
1658
+ logger.info(f"Successfully loaded {len(tool_list)} tools from MCP server '{server_name}'")
1659
+ return mode, tool_list, tool_cache