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.
- lfx/__init__.py +0 -0
- lfx/__main__.py +25 -0
- lfx/_assets/component_index.json +1 -0
- lfx/base/__init__.py +0 -0
- lfx/base/agents/__init__.py +0 -0
- lfx/base/agents/agent.py +375 -0
- lfx/base/agents/altk_base_agent.py +380 -0
- lfx/base/agents/altk_tool_wrappers.py +565 -0
- lfx/base/agents/callback.py +130 -0
- lfx/base/agents/context.py +109 -0
- lfx/base/agents/crewai/__init__.py +0 -0
- lfx/base/agents/crewai/crew.py +231 -0
- lfx/base/agents/crewai/tasks.py +12 -0
- lfx/base/agents/default_prompts.py +23 -0
- lfx/base/agents/errors.py +15 -0
- lfx/base/agents/events.py +430 -0
- lfx/base/agents/utils.py +237 -0
- lfx/base/astra_assistants/__init__.py +0 -0
- lfx/base/astra_assistants/util.py +171 -0
- lfx/base/chains/__init__.py +0 -0
- lfx/base/chains/model.py +19 -0
- lfx/base/composio/__init__.py +0 -0
- lfx/base/composio/composio_base.py +2584 -0
- lfx/base/compressors/__init__.py +0 -0
- lfx/base/compressors/model.py +60 -0
- lfx/base/constants.py +46 -0
- lfx/base/curl/__init__.py +0 -0
- lfx/base/curl/parse.py +188 -0
- lfx/base/data/__init__.py +5 -0
- lfx/base/data/base_file.py +810 -0
- lfx/base/data/docling_utils.py +338 -0
- lfx/base/data/storage_utils.py +192 -0
- lfx/base/data/utils.py +362 -0
- lfx/base/datastax/__init__.py +5 -0
- lfx/base/datastax/astradb_base.py +896 -0
- lfx/base/document_transformers/__init__.py +0 -0
- lfx/base/document_transformers/model.py +43 -0
- lfx/base/embeddings/__init__.py +0 -0
- lfx/base/embeddings/aiml_embeddings.py +62 -0
- lfx/base/embeddings/embeddings_class.py +113 -0
- lfx/base/embeddings/model.py +26 -0
- lfx/base/flow_processing/__init__.py +0 -0
- lfx/base/flow_processing/utils.py +86 -0
- lfx/base/huggingface/__init__.py +0 -0
- lfx/base/huggingface/model_bridge.py +133 -0
- lfx/base/io/__init__.py +0 -0
- lfx/base/io/chat.py +21 -0
- lfx/base/io/text.py +22 -0
- lfx/base/knowledge_bases/__init__.py +3 -0
- lfx/base/knowledge_bases/knowledge_base_utils.py +137 -0
- lfx/base/langchain_utilities/__init__.py +0 -0
- lfx/base/langchain_utilities/model.py +35 -0
- lfx/base/langchain_utilities/spider_constants.py +1 -0
- lfx/base/langwatch/__init__.py +0 -0
- lfx/base/langwatch/utils.py +18 -0
- lfx/base/mcp/__init__.py +0 -0
- lfx/base/mcp/constants.py +2 -0
- lfx/base/mcp/util.py +1659 -0
- lfx/base/memory/__init__.py +0 -0
- lfx/base/memory/memory.py +49 -0
- lfx/base/memory/model.py +38 -0
- lfx/base/models/__init__.py +3 -0
- lfx/base/models/aiml_constants.py +51 -0
- lfx/base/models/anthropic_constants.py +51 -0
- lfx/base/models/aws_constants.py +151 -0
- lfx/base/models/chat_result.py +76 -0
- lfx/base/models/cometapi_constants.py +54 -0
- lfx/base/models/google_generative_ai_constants.py +70 -0
- lfx/base/models/google_generative_ai_model.py +38 -0
- lfx/base/models/groq_constants.py +150 -0
- lfx/base/models/groq_model_discovery.py +265 -0
- lfx/base/models/model.py +375 -0
- lfx/base/models/model_input_constants.py +378 -0
- lfx/base/models/model_metadata.py +41 -0
- lfx/base/models/model_utils.py +108 -0
- lfx/base/models/novita_constants.py +35 -0
- lfx/base/models/ollama_constants.py +52 -0
- lfx/base/models/openai_constants.py +129 -0
- lfx/base/models/sambanova_constants.py +18 -0
- lfx/base/models/watsonx_constants.py +36 -0
- lfx/base/processing/__init__.py +0 -0
- lfx/base/prompts/__init__.py +0 -0
- lfx/base/prompts/api_utils.py +224 -0
- lfx/base/prompts/utils.py +61 -0
- lfx/base/textsplitters/__init__.py +0 -0
- lfx/base/textsplitters/model.py +28 -0
- lfx/base/tools/__init__.py +0 -0
- lfx/base/tools/base.py +26 -0
- lfx/base/tools/component_tool.py +325 -0
- lfx/base/tools/constants.py +49 -0
- lfx/base/tools/flow_tool.py +132 -0
- lfx/base/tools/run_flow.py +698 -0
- lfx/base/vectorstores/__init__.py +0 -0
- lfx/base/vectorstores/model.py +193 -0
- lfx/base/vectorstores/utils.py +22 -0
- lfx/base/vectorstores/vector_store_connection_decorator.py +52 -0
- lfx/cli/__init__.py +5 -0
- lfx/cli/commands.py +327 -0
- lfx/cli/common.py +650 -0
- lfx/cli/run.py +506 -0
- lfx/cli/script_loader.py +289 -0
- lfx/cli/serve_app.py +546 -0
- lfx/cli/validation.py +69 -0
- lfx/components/FAISS/__init__.py +34 -0
- lfx/components/FAISS/faiss.py +111 -0
- lfx/components/Notion/__init__.py +19 -0
- lfx/components/Notion/add_content_to_page.py +269 -0
- lfx/components/Notion/create_page.py +94 -0
- lfx/components/Notion/list_database_properties.py +68 -0
- lfx/components/Notion/list_pages.py +122 -0
- lfx/components/Notion/list_users.py +77 -0
- lfx/components/Notion/page_content_viewer.py +93 -0
- lfx/components/Notion/search.py +111 -0
- lfx/components/Notion/update_page_property.py +114 -0
- lfx/components/__init__.py +428 -0
- lfx/components/_importing.py +42 -0
- lfx/components/agentql/__init__.py +3 -0
- lfx/components/agentql/agentql_api.py +151 -0
- lfx/components/aiml/__init__.py +37 -0
- lfx/components/aiml/aiml.py +115 -0
- lfx/components/aiml/aiml_embeddings.py +37 -0
- lfx/components/altk/__init__.py +34 -0
- lfx/components/altk/altk_agent.py +193 -0
- lfx/components/amazon/__init__.py +36 -0
- lfx/components/amazon/amazon_bedrock_converse.py +195 -0
- lfx/components/amazon/amazon_bedrock_embedding.py +109 -0
- lfx/components/amazon/amazon_bedrock_model.py +130 -0
- lfx/components/amazon/s3_bucket_uploader.py +211 -0
- lfx/components/anthropic/__init__.py +34 -0
- lfx/components/anthropic/anthropic.py +187 -0
- lfx/components/apify/__init__.py +5 -0
- lfx/components/apify/apify_actor.py +325 -0
- lfx/components/arxiv/__init__.py +3 -0
- lfx/components/arxiv/arxiv.py +169 -0
- lfx/components/assemblyai/__init__.py +46 -0
- lfx/components/assemblyai/assemblyai_get_subtitles.py +83 -0
- lfx/components/assemblyai/assemblyai_lemur.py +183 -0
- lfx/components/assemblyai/assemblyai_list_transcripts.py +95 -0
- lfx/components/assemblyai/assemblyai_poll_transcript.py +72 -0
- lfx/components/assemblyai/assemblyai_start_transcript.py +188 -0
- lfx/components/azure/__init__.py +37 -0
- lfx/components/azure/azure_openai.py +95 -0
- lfx/components/azure/azure_openai_embeddings.py +83 -0
- lfx/components/baidu/__init__.py +32 -0
- lfx/components/baidu/baidu_qianfan_chat.py +113 -0
- lfx/components/bing/__init__.py +3 -0
- lfx/components/bing/bing_search_api.py +61 -0
- lfx/components/cassandra/__init__.py +40 -0
- lfx/components/cassandra/cassandra.py +264 -0
- lfx/components/cassandra/cassandra_chat.py +92 -0
- lfx/components/cassandra/cassandra_graph.py +238 -0
- lfx/components/chains/__init__.py +3 -0
- lfx/components/chroma/__init__.py +34 -0
- lfx/components/chroma/chroma.py +169 -0
- lfx/components/cleanlab/__init__.py +40 -0
- lfx/components/cleanlab/cleanlab_evaluator.py +155 -0
- lfx/components/cleanlab/cleanlab_rag_evaluator.py +254 -0
- lfx/components/cleanlab/cleanlab_remediator.py +131 -0
- lfx/components/clickhouse/__init__.py +34 -0
- lfx/components/clickhouse/clickhouse.py +135 -0
- lfx/components/cloudflare/__init__.py +32 -0
- lfx/components/cloudflare/cloudflare.py +81 -0
- lfx/components/cohere/__init__.py +40 -0
- lfx/components/cohere/cohere_embeddings.py +81 -0
- lfx/components/cohere/cohere_models.py +46 -0
- lfx/components/cohere/cohere_rerank.py +51 -0
- lfx/components/cometapi/__init__.py +32 -0
- lfx/components/cometapi/cometapi.py +166 -0
- lfx/components/composio/__init__.py +222 -0
- lfx/components/composio/agentql_composio.py +11 -0
- lfx/components/composio/agiled_composio.py +11 -0
- lfx/components/composio/airtable_composio.py +11 -0
- lfx/components/composio/apollo_composio.py +11 -0
- lfx/components/composio/asana_composio.py +11 -0
- lfx/components/composio/attio_composio.py +11 -0
- lfx/components/composio/bitbucket_composio.py +11 -0
- lfx/components/composio/bolna_composio.py +11 -0
- lfx/components/composio/brightdata_composio.py +11 -0
- lfx/components/composio/calendly_composio.py +11 -0
- lfx/components/composio/canva_composio.py +11 -0
- lfx/components/composio/canvas_composio.py +11 -0
- lfx/components/composio/coda_composio.py +11 -0
- lfx/components/composio/composio_api.py +278 -0
- lfx/components/composio/contentful_composio.py +11 -0
- lfx/components/composio/digicert_composio.py +11 -0
- lfx/components/composio/discord_composio.py +11 -0
- lfx/components/composio/dropbox_compnent.py +11 -0
- lfx/components/composio/elevenlabs_composio.py +11 -0
- lfx/components/composio/exa_composio.py +11 -0
- lfx/components/composio/figma_composio.py +11 -0
- lfx/components/composio/finage_composio.py +11 -0
- lfx/components/composio/firecrawl_composio.py +11 -0
- lfx/components/composio/fireflies_composio.py +11 -0
- lfx/components/composio/fixer_composio.py +11 -0
- lfx/components/composio/flexisign_composio.py +11 -0
- lfx/components/composio/freshdesk_composio.py +11 -0
- lfx/components/composio/github_composio.py +11 -0
- lfx/components/composio/gmail_composio.py +38 -0
- lfx/components/composio/googlebigquery_composio.py +11 -0
- lfx/components/composio/googlecalendar_composio.py +11 -0
- lfx/components/composio/googleclassroom_composio.py +11 -0
- lfx/components/composio/googledocs_composio.py +11 -0
- lfx/components/composio/googlemeet_composio.py +11 -0
- lfx/components/composio/googlesheets_composio.py +11 -0
- lfx/components/composio/googletasks_composio.py +8 -0
- lfx/components/composio/heygen_composio.py +11 -0
- lfx/components/composio/instagram_composio.py +11 -0
- lfx/components/composio/jira_composio.py +11 -0
- lfx/components/composio/jotform_composio.py +11 -0
- lfx/components/composio/klaviyo_composio.py +11 -0
- lfx/components/composio/linear_composio.py +11 -0
- lfx/components/composio/listennotes_composio.py +11 -0
- lfx/components/composio/mem0_composio.py +11 -0
- lfx/components/composio/miro_composio.py +11 -0
- lfx/components/composio/missive_composio.py +11 -0
- lfx/components/composio/notion_composio.py +11 -0
- lfx/components/composio/onedrive_composio.py +11 -0
- lfx/components/composio/outlook_composio.py +11 -0
- lfx/components/composio/pandadoc_composio.py +11 -0
- lfx/components/composio/peopledatalabs_composio.py +11 -0
- lfx/components/composio/perplexityai_composio.py +11 -0
- lfx/components/composio/reddit_composio.py +11 -0
- lfx/components/composio/serpapi_composio.py +11 -0
- lfx/components/composio/slack_composio.py +11 -0
- lfx/components/composio/slackbot_composio.py +11 -0
- lfx/components/composio/snowflake_composio.py +11 -0
- lfx/components/composio/supabase_composio.py +11 -0
- lfx/components/composio/tavily_composio.py +11 -0
- lfx/components/composio/timelinesai_composio.py +11 -0
- lfx/components/composio/todoist_composio.py +11 -0
- lfx/components/composio/wrike_composio.py +11 -0
- lfx/components/composio/youtube_composio.py +11 -0
- lfx/components/confluence/__init__.py +3 -0
- lfx/components/confluence/confluence.py +84 -0
- lfx/components/couchbase/__init__.py +34 -0
- lfx/components/couchbase/couchbase.py +102 -0
- lfx/components/crewai/__init__.py +49 -0
- lfx/components/crewai/crewai.py +108 -0
- lfx/components/crewai/hierarchical_crew.py +47 -0
- lfx/components/crewai/hierarchical_task.py +45 -0
- lfx/components/crewai/sequential_crew.py +53 -0
- lfx/components/crewai/sequential_task.py +74 -0
- lfx/components/crewai/sequential_task_agent.py +144 -0
- lfx/components/cuga/__init__.py +34 -0
- lfx/components/cuga/cuga_agent.py +730 -0
- lfx/components/custom_component/__init__.py +34 -0
- lfx/components/custom_component/custom_component.py +31 -0
- lfx/components/data/__init__.py +114 -0
- lfx/components/data_source/__init__.py +58 -0
- lfx/components/data_source/api_request.py +577 -0
- lfx/components/data_source/csv_to_data.py +101 -0
- lfx/components/data_source/json_to_data.py +106 -0
- lfx/components/data_source/mock_data.py +398 -0
- lfx/components/data_source/news_search.py +166 -0
- lfx/components/data_source/rss.py +71 -0
- lfx/components/data_source/sql_executor.py +101 -0
- lfx/components/data_source/url.py +311 -0
- lfx/components/data_source/web_search.py +326 -0
- lfx/components/datastax/__init__.py +76 -0
- lfx/components/datastax/astradb_assistant_manager.py +307 -0
- lfx/components/datastax/astradb_chatmemory.py +40 -0
- lfx/components/datastax/astradb_cql.py +288 -0
- lfx/components/datastax/astradb_graph.py +217 -0
- lfx/components/datastax/astradb_tool.py +378 -0
- lfx/components/datastax/astradb_vectorize.py +122 -0
- lfx/components/datastax/astradb_vectorstore.py +449 -0
- lfx/components/datastax/create_assistant.py +59 -0
- lfx/components/datastax/create_thread.py +33 -0
- lfx/components/datastax/dotenv.py +36 -0
- lfx/components/datastax/get_assistant.py +38 -0
- lfx/components/datastax/getenvvar.py +31 -0
- lfx/components/datastax/graph_rag.py +141 -0
- lfx/components/datastax/hcd.py +315 -0
- lfx/components/datastax/list_assistants.py +26 -0
- lfx/components/datastax/run.py +90 -0
- lfx/components/deactivated/__init__.py +15 -0
- lfx/components/deactivated/amazon_kendra.py +66 -0
- lfx/components/deactivated/chat_litellm_model.py +158 -0
- lfx/components/deactivated/code_block_extractor.py +26 -0
- lfx/components/deactivated/documents_to_data.py +22 -0
- lfx/components/deactivated/embed.py +16 -0
- lfx/components/deactivated/extract_key_from_data.py +46 -0
- lfx/components/deactivated/json_document_builder.py +57 -0
- lfx/components/deactivated/list_flows.py +20 -0
- lfx/components/deactivated/mcp_sse.py +61 -0
- lfx/components/deactivated/mcp_stdio.py +62 -0
- lfx/components/deactivated/merge_data.py +93 -0
- lfx/components/deactivated/message.py +37 -0
- lfx/components/deactivated/metal.py +54 -0
- lfx/components/deactivated/multi_query.py +59 -0
- lfx/components/deactivated/retriever.py +43 -0
- lfx/components/deactivated/selective_passthrough.py +77 -0
- lfx/components/deactivated/should_run_next.py +40 -0
- lfx/components/deactivated/split_text.py +63 -0
- lfx/components/deactivated/store_message.py +24 -0
- lfx/components/deactivated/sub_flow.py +124 -0
- lfx/components/deactivated/vectara_self_query.py +76 -0
- lfx/components/deactivated/vector_store.py +24 -0
- lfx/components/deepseek/__init__.py +34 -0
- lfx/components/deepseek/deepseek.py +136 -0
- lfx/components/docling/__init__.py +43 -0
- lfx/components/docling/chunk_docling_document.py +186 -0
- lfx/components/docling/docling_inline.py +238 -0
- lfx/components/docling/docling_remote.py +195 -0
- lfx/components/docling/export_docling_document.py +117 -0
- lfx/components/documentloaders/__init__.py +3 -0
- lfx/components/duckduckgo/__init__.py +3 -0
- lfx/components/duckduckgo/duck_duck_go_search_run.py +92 -0
- lfx/components/elastic/__init__.py +37 -0
- lfx/components/elastic/elasticsearch.py +267 -0
- lfx/components/elastic/opensearch.py +789 -0
- lfx/components/elastic/opensearch_multimodal.py +1575 -0
- lfx/components/embeddings/__init__.py +37 -0
- lfx/components/embeddings/similarity.py +77 -0
- lfx/components/embeddings/text_embedder.py +65 -0
- lfx/components/exa/__init__.py +3 -0
- lfx/components/exa/exa_search.py +68 -0
- lfx/components/files_and_knowledge/__init__.py +47 -0
- lfx/components/files_and_knowledge/directory.py +113 -0
- lfx/components/files_and_knowledge/file.py +841 -0
- lfx/components/files_and_knowledge/ingestion.py +694 -0
- lfx/components/files_and_knowledge/retrieval.py +264 -0
- lfx/components/files_and_knowledge/save_file.py +746 -0
- lfx/components/firecrawl/__init__.py +43 -0
- lfx/components/firecrawl/firecrawl_crawl_api.py +88 -0
- lfx/components/firecrawl/firecrawl_extract_api.py +136 -0
- lfx/components/firecrawl/firecrawl_map_api.py +89 -0
- lfx/components/firecrawl/firecrawl_scrape_api.py +73 -0
- lfx/components/flow_controls/__init__.py +58 -0
- lfx/components/flow_controls/conditional_router.py +208 -0
- lfx/components/flow_controls/data_conditional_router.py +126 -0
- lfx/components/flow_controls/flow_tool.py +111 -0
- lfx/components/flow_controls/listen.py +29 -0
- lfx/components/flow_controls/loop.py +163 -0
- lfx/components/flow_controls/notify.py +88 -0
- lfx/components/flow_controls/pass_message.py +36 -0
- lfx/components/flow_controls/run_flow.py +108 -0
- lfx/components/flow_controls/sub_flow.py +115 -0
- lfx/components/git/__init__.py +4 -0
- lfx/components/git/git.py +262 -0
- lfx/components/git/gitextractor.py +196 -0
- lfx/components/glean/__init__.py +3 -0
- lfx/components/glean/glean_search_api.py +173 -0
- lfx/components/google/__init__.py +17 -0
- lfx/components/google/gmail.py +193 -0
- lfx/components/google/google_bq_sql_executor.py +157 -0
- lfx/components/google/google_drive.py +92 -0
- lfx/components/google/google_drive_search.py +152 -0
- lfx/components/google/google_generative_ai.py +144 -0
- lfx/components/google/google_generative_ai_embeddings.py +141 -0
- lfx/components/google/google_oauth_token.py +89 -0
- lfx/components/google/google_search_api_core.py +68 -0
- lfx/components/google/google_serper_api_core.py +74 -0
- lfx/components/groq/__init__.py +34 -0
- lfx/components/groq/groq.py +143 -0
- lfx/components/helpers/__init__.py +154 -0
- lfx/components/homeassistant/__init__.py +7 -0
- lfx/components/homeassistant/home_assistant_control.py +152 -0
- lfx/components/homeassistant/list_home_assistant_states.py +137 -0
- lfx/components/huggingface/__init__.py +37 -0
- lfx/components/huggingface/huggingface.py +199 -0
- lfx/components/huggingface/huggingface_inference_api.py +106 -0
- lfx/components/ibm/__init__.py +34 -0
- lfx/components/ibm/watsonx.py +207 -0
- lfx/components/ibm/watsonx_embeddings.py +135 -0
- lfx/components/icosacomputing/__init__.py +5 -0
- lfx/components/icosacomputing/combinatorial_reasoner.py +84 -0
- lfx/components/input_output/__init__.py +40 -0
- lfx/components/input_output/chat.py +109 -0
- lfx/components/input_output/chat_output.py +184 -0
- lfx/components/input_output/text.py +27 -0
- lfx/components/input_output/text_output.py +29 -0
- lfx/components/input_output/webhook.py +56 -0
- lfx/components/jigsawstack/__init__.py +23 -0
- lfx/components/jigsawstack/ai_scrape.py +126 -0
- lfx/components/jigsawstack/ai_web_search.py +136 -0
- lfx/components/jigsawstack/file_read.py +115 -0
- lfx/components/jigsawstack/file_upload.py +94 -0
- lfx/components/jigsawstack/image_generation.py +205 -0
- lfx/components/jigsawstack/nsfw.py +60 -0
- lfx/components/jigsawstack/object_detection.py +124 -0
- lfx/components/jigsawstack/sentiment.py +112 -0
- lfx/components/jigsawstack/text_to_sql.py +90 -0
- lfx/components/jigsawstack/text_translate.py +77 -0
- lfx/components/jigsawstack/vocr.py +107 -0
- lfx/components/knowledge_bases/__init__.py +89 -0
- lfx/components/langchain_utilities/__init__.py +109 -0
- lfx/components/langchain_utilities/character.py +53 -0
- lfx/components/langchain_utilities/conversation.py +59 -0
- lfx/components/langchain_utilities/csv_agent.py +175 -0
- lfx/components/langchain_utilities/fake_embeddings.py +26 -0
- lfx/components/langchain_utilities/html_link_extractor.py +35 -0
- lfx/components/langchain_utilities/json_agent.py +100 -0
- lfx/components/langchain_utilities/langchain_hub.py +126 -0
- lfx/components/langchain_utilities/language_recursive.py +49 -0
- lfx/components/langchain_utilities/language_semantic.py +138 -0
- lfx/components/langchain_utilities/llm_checker.py +39 -0
- lfx/components/langchain_utilities/llm_math.py +42 -0
- lfx/components/langchain_utilities/natural_language.py +61 -0
- lfx/components/langchain_utilities/openai_tools.py +53 -0
- lfx/components/langchain_utilities/openapi.py +48 -0
- lfx/components/langchain_utilities/recursive_character.py +60 -0
- lfx/components/langchain_utilities/retrieval_qa.py +83 -0
- lfx/components/langchain_utilities/runnable_executor.py +137 -0
- lfx/components/langchain_utilities/self_query.py +80 -0
- lfx/components/langchain_utilities/spider.py +142 -0
- lfx/components/langchain_utilities/sql.py +40 -0
- lfx/components/langchain_utilities/sql_database.py +35 -0
- lfx/components/langchain_utilities/sql_generator.py +78 -0
- lfx/components/langchain_utilities/tool_calling.py +59 -0
- lfx/components/langchain_utilities/vector_store_info.py +49 -0
- lfx/components/langchain_utilities/vector_store_router.py +33 -0
- lfx/components/langchain_utilities/xml_agent.py +71 -0
- lfx/components/langwatch/__init__.py +3 -0
- lfx/components/langwatch/langwatch.py +278 -0
- lfx/components/link_extractors/__init__.py +3 -0
- lfx/components/llm_operations/__init__.py +46 -0
- lfx/components/llm_operations/batch_run.py +205 -0
- lfx/components/llm_operations/lambda_filter.py +218 -0
- lfx/components/llm_operations/llm_conditional_router.py +421 -0
- lfx/components/llm_operations/llm_selector.py +499 -0
- lfx/components/llm_operations/structured_output.py +244 -0
- lfx/components/lmstudio/__init__.py +34 -0
- lfx/components/lmstudio/lmstudioembeddings.py +89 -0
- lfx/components/lmstudio/lmstudiomodel.py +133 -0
- lfx/components/logic/__init__.py +181 -0
- lfx/components/maritalk/__init__.py +32 -0
- lfx/components/maritalk/maritalk.py +52 -0
- lfx/components/mem0/__init__.py +3 -0
- lfx/components/mem0/mem0_chat_memory.py +147 -0
- lfx/components/milvus/__init__.py +34 -0
- lfx/components/milvus/milvus.py +115 -0
- lfx/components/mistral/__init__.py +37 -0
- lfx/components/mistral/mistral.py +114 -0
- lfx/components/mistral/mistral_embeddings.py +58 -0
- lfx/components/models/__init__.py +89 -0
- lfx/components/models_and_agents/__init__.py +49 -0
- lfx/components/models_and_agents/agent.py +644 -0
- lfx/components/models_and_agents/embedding_model.py +423 -0
- lfx/components/models_and_agents/language_model.py +398 -0
- lfx/components/models_and_agents/mcp_component.py +594 -0
- lfx/components/models_and_agents/memory.py +268 -0
- lfx/components/models_and_agents/prompt.py +67 -0
- lfx/components/mongodb/__init__.py +34 -0
- lfx/components/mongodb/mongodb_atlas.py +213 -0
- lfx/components/needle/__init__.py +3 -0
- lfx/components/needle/needle.py +104 -0
- lfx/components/notdiamond/__init__.py +34 -0
- lfx/components/notdiamond/notdiamond.py +228 -0
- lfx/components/novita/__init__.py +32 -0
- lfx/components/novita/novita.py +130 -0
- lfx/components/nvidia/__init__.py +57 -0
- lfx/components/nvidia/nvidia.py +151 -0
- lfx/components/nvidia/nvidia_embedding.py +77 -0
- lfx/components/nvidia/nvidia_ingest.py +317 -0
- lfx/components/nvidia/nvidia_rerank.py +63 -0
- lfx/components/nvidia/system_assist.py +65 -0
- lfx/components/olivya/__init__.py +3 -0
- lfx/components/olivya/olivya.py +116 -0
- lfx/components/ollama/__init__.py +37 -0
- lfx/components/ollama/ollama.py +548 -0
- lfx/components/ollama/ollama_embeddings.py +103 -0
- lfx/components/openai/__init__.py +37 -0
- lfx/components/openai/openai.py +100 -0
- lfx/components/openai/openai_chat_model.py +176 -0
- lfx/components/openrouter/__init__.py +32 -0
- lfx/components/openrouter/openrouter.py +104 -0
- lfx/components/output_parsers/__init__.py +3 -0
- lfx/components/perplexity/__init__.py +34 -0
- lfx/components/perplexity/perplexity.py +75 -0
- lfx/components/pgvector/__init__.py +34 -0
- lfx/components/pgvector/pgvector.py +72 -0
- lfx/components/pinecone/__init__.py +34 -0
- lfx/components/pinecone/pinecone.py +134 -0
- lfx/components/processing/__init__.py +72 -0
- lfx/components/processing/alter_metadata.py +109 -0
- lfx/components/processing/combine_text.py +40 -0
- lfx/components/processing/converter.py +248 -0
- lfx/components/processing/create_data.py +111 -0
- lfx/components/processing/create_list.py +40 -0
- lfx/components/processing/data_operations.py +528 -0
- lfx/components/processing/data_to_dataframe.py +71 -0
- lfx/components/processing/dataframe_operations.py +313 -0
- lfx/components/processing/dataframe_to_toolset.py +259 -0
- lfx/components/processing/dynamic_create_data.py +357 -0
- lfx/components/processing/extract_key.py +54 -0
- lfx/components/processing/filter_data.py +43 -0
- lfx/components/processing/filter_data_values.py +89 -0
- lfx/components/processing/json_cleaner.py +104 -0
- lfx/components/processing/merge_data.py +91 -0
- lfx/components/processing/message_to_data.py +37 -0
- lfx/components/processing/output_parser.py +46 -0
- lfx/components/processing/parse_data.py +71 -0
- lfx/components/processing/parse_dataframe.py +69 -0
- lfx/components/processing/parse_json_data.py +91 -0
- lfx/components/processing/parser.py +148 -0
- lfx/components/processing/regex.py +83 -0
- lfx/components/processing/select_data.py +49 -0
- lfx/components/processing/split_text.py +141 -0
- lfx/components/processing/store_message.py +91 -0
- lfx/components/processing/update_data.py +161 -0
- lfx/components/prototypes/__init__.py +35 -0
- lfx/components/prototypes/python_function.py +73 -0
- lfx/components/qdrant/__init__.py +34 -0
- lfx/components/qdrant/qdrant.py +109 -0
- lfx/components/redis/__init__.py +37 -0
- lfx/components/redis/redis.py +89 -0
- lfx/components/redis/redis_chat.py +43 -0
- lfx/components/sambanova/__init__.py +32 -0
- lfx/components/sambanova/sambanova.py +84 -0
- lfx/components/scrapegraph/__init__.py +40 -0
- lfx/components/scrapegraph/scrapegraph_markdownify_api.py +64 -0
- lfx/components/scrapegraph/scrapegraph_search_api.py +64 -0
- lfx/components/scrapegraph/scrapegraph_smart_scraper_api.py +71 -0
- lfx/components/searchapi/__init__.py +34 -0
- lfx/components/searchapi/search.py +79 -0
- lfx/components/serpapi/__init__.py +3 -0
- lfx/components/serpapi/serp.py +115 -0
- lfx/components/supabase/__init__.py +34 -0
- lfx/components/supabase/supabase.py +76 -0
- lfx/components/tavily/__init__.py +4 -0
- lfx/components/tavily/tavily_extract.py +117 -0
- lfx/components/tavily/tavily_search.py +212 -0
- lfx/components/textsplitters/__init__.py +3 -0
- lfx/components/toolkits/__init__.py +3 -0
- lfx/components/tools/__init__.py +66 -0
- lfx/components/tools/calculator.py +109 -0
- lfx/components/tools/google_search_api.py +45 -0
- lfx/components/tools/google_serper_api.py +115 -0
- lfx/components/tools/python_code_structured_tool.py +328 -0
- lfx/components/tools/python_repl.py +98 -0
- lfx/components/tools/search_api.py +88 -0
- lfx/components/tools/searxng.py +145 -0
- lfx/components/tools/serp_api.py +120 -0
- lfx/components/tools/tavily_search_tool.py +345 -0
- lfx/components/tools/wikidata_api.py +103 -0
- lfx/components/tools/wikipedia_api.py +50 -0
- lfx/components/tools/yahoo_finance.py +130 -0
- lfx/components/twelvelabs/__init__.py +52 -0
- lfx/components/twelvelabs/convert_astra_results.py +84 -0
- lfx/components/twelvelabs/pegasus_index.py +311 -0
- lfx/components/twelvelabs/split_video.py +301 -0
- lfx/components/twelvelabs/text_embeddings.py +57 -0
- lfx/components/twelvelabs/twelvelabs_pegasus.py +408 -0
- lfx/components/twelvelabs/video_embeddings.py +100 -0
- lfx/components/twelvelabs/video_file.py +191 -0
- lfx/components/unstructured/__init__.py +3 -0
- lfx/components/unstructured/unstructured.py +121 -0
- lfx/components/upstash/__init__.py +34 -0
- lfx/components/upstash/upstash.py +124 -0
- lfx/components/utilities/__init__.py +43 -0
- lfx/components/utilities/calculator_core.py +89 -0
- lfx/components/utilities/current_date.py +42 -0
- lfx/components/utilities/id_generator.py +42 -0
- lfx/components/utilities/python_repl_core.py +98 -0
- lfx/components/vectara/__init__.py +37 -0
- lfx/components/vectara/vectara.py +97 -0
- lfx/components/vectara/vectara_rag.py +164 -0
- lfx/components/vectorstores/__init__.py +34 -0
- lfx/components/vectorstores/local_db.py +270 -0
- lfx/components/vertexai/__init__.py +37 -0
- lfx/components/vertexai/vertexai.py +71 -0
- lfx/components/vertexai/vertexai_embeddings.py +67 -0
- lfx/components/vlmrun/__init__.py +34 -0
- lfx/components/vlmrun/vlmrun_transcription.py +224 -0
- lfx/components/weaviate/__init__.py +34 -0
- lfx/components/weaviate/weaviate.py +89 -0
- lfx/components/wikipedia/__init__.py +4 -0
- lfx/components/wikipedia/wikidata.py +86 -0
- lfx/components/wikipedia/wikipedia.py +53 -0
- lfx/components/wolframalpha/__init__.py +3 -0
- lfx/components/wolframalpha/wolfram_alpha_api.py +54 -0
- lfx/components/xai/__init__.py +32 -0
- lfx/components/xai/xai.py +167 -0
- lfx/components/yahoosearch/__init__.py +3 -0
- lfx/components/yahoosearch/yahoo.py +137 -0
- lfx/components/youtube/__init__.py +52 -0
- lfx/components/youtube/channel.py +227 -0
- lfx/components/youtube/comments.py +231 -0
- lfx/components/youtube/playlist.py +33 -0
- lfx/components/youtube/search.py +120 -0
- lfx/components/youtube/trending.py +285 -0
- lfx/components/youtube/video_details.py +263 -0
- lfx/components/youtube/youtube_transcripts.py +206 -0
- lfx/components/zep/__init__.py +3 -0
- lfx/components/zep/zep.py +45 -0
- lfx/constants.py +6 -0
- lfx/custom/__init__.py +7 -0
- lfx/custom/attributes.py +87 -0
- lfx/custom/code_parser/__init__.py +3 -0
- lfx/custom/code_parser/code_parser.py +361 -0
- lfx/custom/custom_component/__init__.py +0 -0
- lfx/custom/custom_component/base_component.py +128 -0
- lfx/custom/custom_component/component.py +1890 -0
- lfx/custom/custom_component/component_with_cache.py +8 -0
- lfx/custom/custom_component/custom_component.py +650 -0
- lfx/custom/dependency_analyzer.py +165 -0
- lfx/custom/directory_reader/__init__.py +3 -0
- lfx/custom/directory_reader/directory_reader.py +359 -0
- lfx/custom/directory_reader/utils.py +171 -0
- lfx/custom/eval.py +12 -0
- lfx/custom/schema.py +32 -0
- lfx/custom/tree_visitor.py +21 -0
- lfx/custom/utils.py +877 -0
- lfx/custom/validate.py +523 -0
- lfx/events/__init__.py +1 -0
- lfx/events/event_manager.py +110 -0
- lfx/exceptions/__init__.py +0 -0
- lfx/exceptions/component.py +15 -0
- lfx/field_typing/__init__.py +91 -0
- lfx/field_typing/constants.py +216 -0
- lfx/field_typing/range_spec.py +35 -0
- lfx/graph/__init__.py +6 -0
- lfx/graph/edge/__init__.py +0 -0
- lfx/graph/edge/base.py +300 -0
- lfx/graph/edge/schema.py +119 -0
- lfx/graph/edge/utils.py +0 -0
- lfx/graph/graph/__init__.py +0 -0
- lfx/graph/graph/ascii.py +202 -0
- lfx/graph/graph/base.py +2298 -0
- lfx/graph/graph/constants.py +63 -0
- lfx/graph/graph/runnable_vertices_manager.py +133 -0
- lfx/graph/graph/schema.py +53 -0
- lfx/graph/graph/state_model.py +66 -0
- lfx/graph/graph/utils.py +1024 -0
- lfx/graph/schema.py +75 -0
- lfx/graph/state/__init__.py +0 -0
- lfx/graph/state/model.py +250 -0
- lfx/graph/utils.py +206 -0
- lfx/graph/vertex/__init__.py +0 -0
- lfx/graph/vertex/base.py +826 -0
- lfx/graph/vertex/constants.py +0 -0
- lfx/graph/vertex/exceptions.py +4 -0
- lfx/graph/vertex/param_handler.py +316 -0
- lfx/graph/vertex/schema.py +26 -0
- lfx/graph/vertex/utils.py +19 -0
- lfx/graph/vertex/vertex_types.py +489 -0
- lfx/helpers/__init__.py +141 -0
- lfx/helpers/base_model.py +71 -0
- lfx/helpers/custom.py +13 -0
- lfx/helpers/data.py +167 -0
- lfx/helpers/flow.py +308 -0
- lfx/inputs/__init__.py +68 -0
- lfx/inputs/constants.py +2 -0
- lfx/inputs/input_mixin.py +352 -0
- lfx/inputs/inputs.py +718 -0
- lfx/inputs/validators.py +19 -0
- lfx/interface/__init__.py +6 -0
- lfx/interface/components.py +897 -0
- lfx/interface/importing/__init__.py +5 -0
- lfx/interface/importing/utils.py +39 -0
- lfx/interface/initialize/__init__.py +3 -0
- lfx/interface/initialize/loading.py +317 -0
- lfx/interface/listing.py +26 -0
- lfx/interface/run.py +16 -0
- lfx/interface/utils.py +111 -0
- lfx/io/__init__.py +63 -0
- lfx/io/schema.py +295 -0
- lfx/load/__init__.py +8 -0
- lfx/load/load.py +256 -0
- lfx/load/utils.py +99 -0
- lfx/log/__init__.py +5 -0
- lfx/log/logger.py +411 -0
- lfx/logging/__init__.py +11 -0
- lfx/logging/logger.py +24 -0
- lfx/memory/__init__.py +70 -0
- lfx/memory/stubs.py +302 -0
- lfx/processing/__init__.py +1 -0
- lfx/processing/process.py +238 -0
- lfx/processing/utils.py +25 -0
- lfx/py.typed +0 -0
- lfx/schema/__init__.py +66 -0
- lfx/schema/artifact.py +83 -0
- lfx/schema/content_block.py +62 -0
- lfx/schema/content_types.py +91 -0
- lfx/schema/cross_module.py +80 -0
- lfx/schema/data.py +309 -0
- lfx/schema/dataframe.py +210 -0
- lfx/schema/dotdict.py +74 -0
- lfx/schema/encoders.py +13 -0
- lfx/schema/graph.py +47 -0
- lfx/schema/image.py +184 -0
- lfx/schema/json_schema.py +186 -0
- lfx/schema/log.py +62 -0
- lfx/schema/message.py +493 -0
- lfx/schema/openai_responses_schemas.py +74 -0
- lfx/schema/properties.py +41 -0
- lfx/schema/schema.py +180 -0
- lfx/schema/serialize.py +13 -0
- lfx/schema/table.py +142 -0
- lfx/schema/validators.py +114 -0
- lfx/serialization/__init__.py +5 -0
- lfx/serialization/constants.py +2 -0
- lfx/serialization/serialization.py +314 -0
- lfx/services/__init__.py +26 -0
- lfx/services/base.py +28 -0
- lfx/services/cache/__init__.py +6 -0
- lfx/services/cache/base.py +183 -0
- lfx/services/cache/service.py +166 -0
- lfx/services/cache/utils.py +169 -0
- lfx/services/chat/__init__.py +1 -0
- lfx/services/chat/config.py +2 -0
- lfx/services/chat/schema.py +10 -0
- lfx/services/database/__init__.py +5 -0
- lfx/services/database/service.py +25 -0
- lfx/services/deps.py +194 -0
- lfx/services/factory.py +19 -0
- lfx/services/initialize.py +19 -0
- lfx/services/interfaces.py +103 -0
- lfx/services/manager.py +185 -0
- lfx/services/mcp_composer/__init__.py +6 -0
- lfx/services/mcp_composer/factory.py +16 -0
- lfx/services/mcp_composer/service.py +1441 -0
- lfx/services/schema.py +21 -0
- lfx/services/session.py +87 -0
- lfx/services/settings/__init__.py +3 -0
- lfx/services/settings/auth.py +133 -0
- lfx/services/settings/base.py +668 -0
- lfx/services/settings/constants.py +43 -0
- lfx/services/settings/factory.py +23 -0
- lfx/services/settings/feature_flags.py +11 -0
- lfx/services/settings/service.py +35 -0
- lfx/services/settings/utils.py +40 -0
- lfx/services/shared_component_cache/__init__.py +1 -0
- lfx/services/shared_component_cache/factory.py +30 -0
- lfx/services/shared_component_cache/service.py +9 -0
- lfx/services/storage/__init__.py +5 -0
- lfx/services/storage/local.py +185 -0
- lfx/services/storage/service.py +177 -0
- lfx/services/tracing/__init__.py +1 -0
- lfx/services/tracing/service.py +21 -0
- lfx/settings.py +6 -0
- lfx/template/__init__.py +6 -0
- lfx/template/field/__init__.py +0 -0
- lfx/template/field/base.py +260 -0
- lfx/template/field/prompt.py +15 -0
- lfx/template/frontend_node/__init__.py +6 -0
- lfx/template/frontend_node/base.py +214 -0
- lfx/template/frontend_node/constants.py +65 -0
- lfx/template/frontend_node/custom_components.py +79 -0
- lfx/template/template/__init__.py +0 -0
- lfx/template/template/base.py +100 -0
- lfx/template/utils.py +217 -0
- lfx/type_extraction/__init__.py +19 -0
- lfx/type_extraction/type_extraction.py +75 -0
- lfx/type_extraction.py +80 -0
- lfx/utils/__init__.py +1 -0
- lfx/utils/async_helpers.py +42 -0
- lfx/utils/component_utils.py +154 -0
- lfx/utils/concurrency.py +60 -0
- lfx/utils/connection_string_parser.py +11 -0
- lfx/utils/constants.py +233 -0
- lfx/utils/data_structure.py +212 -0
- lfx/utils/exceptions.py +22 -0
- lfx/utils/helpers.py +34 -0
- lfx/utils/image.py +79 -0
- lfx/utils/langflow_utils.py +52 -0
- lfx/utils/lazy_load.py +15 -0
- lfx/utils/request_utils.py +18 -0
- lfx/utils/schemas.py +139 -0
- lfx/utils/ssrf_protection.py +384 -0
- lfx/utils/util.py +626 -0
- lfx/utils/util_strings.py +56 -0
- lfx/utils/validate_cloud.py +26 -0
- lfx/utils/version.py +24 -0
- lfx_nightly-0.2.0.dev25.dist-info/METADATA +312 -0
- lfx_nightly-0.2.0.dev25.dist-info/RECORD +769 -0
- lfx_nightly-0.2.0.dev25.dist-info/WHEEL +4 -0
- 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
|