From 3bc012440f2eddc0422d7b1611ff74ffb8c8ac45 Mon Sep 17 00:00:00 2001 From: Mansura Habiba Date: Tue, 21 Apr 2026 15:26:21 +0100 Subject: [PATCH] fix: mcp component dyanamic tool (#12779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): support real-time tool onboarding via per-request auth headers Problem ------- When Langflow runs an agent that has an MCPTools component attached and an API request supplies authentication headers via tweaks, the MCP server can legitimately return an *expanded* tool list that is broader than the unauthenticated base set (the server filters its listing by the caller's identity). Today those extra tools never reach the agent at runtime: the component always binds the first preloaded tool list and ignores the new auth context for the lifetime of the flow. Root cause ---------- Several caching / binding layers combine to prevent per-request tool refresh: 1. Flow JSON persists ``component_as_tool`` with ``cache: true`` and Langflow's ``Output`` memoization reuses the first ``to_toolkit()`` result for all subsequent requests regardless of headers. 2. The shared MCP server cache was keyed by server name only, so requests with different auth contexts collided on the same cache slot. 3. In tool-mode, ``_get_tools()`` returned ``[]`` when ``_not_load_actions`` was true, starving the agent of MCP tools. 4. Concurrent ``update_tool_list`` calls raced on the same Streamable HTTP client session, producing intermittent HTTP 404 / "Session terminated" errors from the MCP SDK. What changed ------------ ``src/lfx/src/lfx/components/models_and_agents/mcp_component.py``: - FIX 1 - Output cache bypass: ``_build_tool_output`` declares the Toolset output with ``cache=False``, and ``map_outputs`` overrides any ``cache: true`` value persisted in saved flow JSON. Together these guarantee every run resolves a fresh ``to_toolkit()`` call regardless of whether the flow was loaded from disk. Independent of the user-facing ``use_cache`` / "Use Cached Server" toggle. - FIX 2 - Header-aware cache key: ``_mcp_servers_cache_key`` now hashes the component headers into the shared server-cache key so requests with different auth contexts land in distinct cache slots instead of masking each other. The shared cache is bounded at ``SHARED_SERVERS_CACHE_MAX_ENTRIES`` = 64 with FIFO eviction so a tenant rotating session tokens does not grow the map without limit. - FIX 3 - ``_get_tools`` always fetches: removed the ``_not_load_actions`` short-circuit that returned ``[]`` in tool-mode, so the agent always binds the current (possibly expanded) tool list. ``_not_load_actions`` still gates only the UI build_config dropdown. - FIX 4 - Serialized ``update_tool_list``: an ``asyncio.Lock`` prevents concurrent refreshes from racing on the same Streamable HTTP client session, eliminating the intermittent HTTP 404 / "Session terminated" errors the MCP SDK raised when session DELETE and POST overlapped. - FIX 5 - TTL tool cache: ``_get_tools`` keeps a per-instance, header-hash-keyed TTL cache (``TOOL_TTL_SECS`` = 30s, disable with 0) bounded at ``TOOL_TTL_MAX_ENTRIES`` = 32 with FIFO eviction and stale-on-read drop. Parallel agent steps that share the same auth reuse the tool list instead of each paying for a fresh MCP round-trip. This is deliberately distinct from ``use_cache`` / "Use Cached Server" which controls the cross-request shared cache. The dict is initialized in ``__init__`` so every instance gets its own cache. Impact on users --------------- - Backward compatible. Unauthenticated / non-tweaked flows behave identically: the TTL cache is per-instance and scoped to a single (server, header-hash) pair, and the base-class ``to_toolkit`` chain is unchanged (no override of filtering via ``tools_metadata`` / ``enabled_tools``). - New behaviour only triggers when tweaks/UI headers include an auth context; the header-hash cache key then isolates per-caller tool lists and the agent binds the correctly-filtered expanded set. - No changes to ``lfx/services/settings/base.py``: the global ``mcp_server_timeout`` default stays at its existing value. Test plan --------- - ``python -m py_compile`` passes on the edited module. - ``python -m ruff check`` reports no new violations introduced by this change. - Local verification against an MCP server that returns different tool lists per caller identity: * Unauthenticated request returns the base tools as before. * Authenticated request (auth headers via tweaks) returns the expanded tool set on the first call and on subsequent calls with the same auth - TTL cache hits log ``MCP _get_tools: TTL cache hit``. * Switching to a different auth context on the same flow returns the correct per-caller tool list (header-hash cache key isolates the two contexts). * Parallel agent runs no longer surface HTTP 404 / "Session terminated" from the MCP SDK. * Disabling a tool via the UI (``tools_metadata``) correctly hides it from the bound agent - base-class filtering is preserved. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * test(mcp): cover dynamic-tool onboarding fixes Add unit coverage for the fixes introduced in this PR so regressions in the cache-key, TTL cache, concurrency lock, shared-cache eviction, and Toolset Output.cache=False behaviour are caught by CI instead of by users: - Header normalization across list / dict / None / malformed shapes - `_mcp_servers_cache_key` determinism + header-hash scoping across auth contexts (different headers → different keys; order-independent) - Per-instance `_ttl_tool_cache` isolation (no cross-instance leakage) - `_get_tools` TTL cache: hit skips `update_tool_list`, expired entries are refetched, FIFO-eviction caps growth at `TOOL_TTL_MAX_ENTRIES`, and TTL=0 disables the cache - `_update_tool_list_lock` serialises concurrent calls (peak concurrency 1) - Shared `servers` cross-request cache evicts oldest entry when the map reaches `SHARED_SERVERS_CACHE_MAX_ENTRIES` - `_build_tool_output` declares `cache=False`; `map_outputs` overrides persisted `cache=True` from saved flow JSON * [autofix.ci] apply automated fixes --------- Co-authored-by: Mansura Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Hare --- .../starter_projects/Nvidia Remix.json | 4 +- .../test_mcp_component_dynamic.py | 319 +++++++++++ src/lfx/src/lfx/_assets/component_index.json | 6 +- .../models_and_agents/mcp_component.py | 503 ++++++++++++------ 4 files changed, 669 insertions(+), 163 deletions(-) create mode 100644 src/backend/tests/unit/components/models_and_agents/test_mcp_component_dynamic.py diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json index 28b0834593..bc95254bac 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json @@ -2100,7 +2100,7 @@ "legacy": false, "lf_version": "1.4.2", "metadata": { - "code_hash": "61d92e5d1d83", + "code_hash": "2c44f0d75467", "dependencies": { "dependencies": [ { @@ -2166,7 +2166,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport uuid\nfrom types import UnionType\nfrom typing import Any, get_args, get_origin\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\nfrom pydantic import BaseModel\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import (\n MCPStdioClient,\n MCPStreamableHttpClient,\n update_tools,\n)\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_storage_service, session_scope\n\n\ndef resolve_mcp_config(\n server_name: str, # noqa: ARG001\n server_config_from_value: dict | None,\n server_config_from_db: dict | None,\n) -> dict | None:\n \"\"\"Resolve MCP server config with proper precedence.\n\n Resolves the configuration for an MCP server with the following precedence:\n 1. Database config (takes priority) - ensures edits are reflected\n 2. Config from value/tweaks (fallback) - allows REST API to provide config for new servers\n\n Args:\n server_name: Name of the MCP server\n server_config_from_value: Config provided via value/tweaks (optional)\n server_config_from_db: Config from database (optional)\n\n Returns:\n Final config to use (DB takes priority, falls back to value)\n Returns None if no config found in either location\n \"\"\"\n if server_config_from_db:\n return server_config_from_db\n return server_config_from_value\n\n\nclass MCPToolsComponent(ComponentWithCache):\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(\n component_cache=self._shared_component_cache\n )\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n \"verify_ssl\",\n \"headers\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-tools\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n BoolInput(\n name=\"verify_ssl\",\n display_name=\"Verify SSL Certificate\",\n info=(\n \"Enable SSL certificate verification for HTTPS connections. \"\n \"Disable only for development/testing with self-signed certificates.\"\n ),\n value=True,\n advanced=True,\n ),\n DictInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=(\n \"HTTP headers to include with MCP server requests. \"\n \"Useful for authentication (e.g., Authorization header). \"\n \"These headers override any headers configured in the MCP server settings.\"\n ),\n advanced=True,\n is_list=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n refresh_button=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n input_schema = tool_obj.args_schema\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(server_name) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n self.tools = cached[\"tools\"]\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and server_name in current_servers_cache:\n current_servers_cache.pop(server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n # Try to fetch from database first to ensure we have the latest config\n # This ensures database updates (like editing a server) take effect\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n\n from lfx.services.deps import get_settings_service\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n\n server_config_from_db = None\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config_from_db = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # Resolve config with proper precedence: DB takes priority, falls back to value\n server_config = resolve_mcp_config(\n server_name=server_name,\n server_config_from_value=server_config_from_value,\n server_config_from_db=server_config_from_db,\n )\n\n if not server_config:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config}\n\n # Add verify_ssl option to server config if not present\n if \"verify_ssl\" not in server_config:\n verify_ssl = getattr(self, \"verify_ssl\", True)\n server_config[\"verify_ssl\"] = verify_ssl\n\n # Merge headers from component input with server config headers\n # Component headers take precedence over server config headers\n component_headers = getattr(self, \"headers\", None) or []\n if component_headers:\n # Convert list of {\"key\": k, \"value\": v} to dict\n component_headers_dict = {}\n if isinstance(component_headers, list):\n for item in component_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n component_headers_dict[item[\"key\"]] = item[\"value\"]\n elif isinstance(component_headers, dict):\n component_headers_dict = component_headers\n\n if component_headers_dict:\n existing_headers = server_config.get(\"headers\", {}) or {}\n # Ensure existing_headers is a dict (convert from list if needed)\n if isinstance(existing_headers, list):\n existing_dict = {}\n for item in existing_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n existing_dict[item[\"key\"]] = item[\"value\"]\n existing_headers = existing_dict\n merged_headers = {**existing_headers, **component_headers_dict}\n server_config[\"headers\"] = merged_headers\n # Get request_variables from graph context for global variable resolution\n request_variables = None\n if hasattr(self, \"graph\") and self.graph and hasattr(self.graph, \"context\"):\n request_variables = self.graph.context.get(\"request_variables\")\n\n # Only load global variables from database if we have headers that might use them\n # This avoids unnecessary database queries when headers are empty\n has_headers = server_config.get(\"headers\") and len(server_config.get(\"headers\", {})) > 0\n if not request_variables and has_headers:\n try:\n from lfx.services.deps import get_variable_service\n\n variable_service = get_variable_service()\n if variable_service:\n async with session_scope() as db:\n request_variables = await variable_service.get_all_decrypted_variables(\n user_id=self.user_id, session=db\n )\n except Exception as e: # noqa: BLE001\n await logger.awarning(f\"Failed to load global variables for MCP component: {e}\")\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_streamable_http_client=self.streamable_http_client,\n request_variables=request_variables,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n current_servers_cache[server_name] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n # Always refresh tools when cache is disabled, or when tools list is empty\n # This ensures database edits are reflected immediately when cache is disabled\n use_cache = getattr(self, \"use_cache\", False)\n if len(self.tools) == 0 or not use_cache:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n # Only treat as a server change if there was a previous server selection.\n # Cold cache (_last_selected_server=\"\") on initial flow load is NOT a server change —\n # the user didn't switch anything, the backend just hasn't seen this component yet.\n server_changed = bool(_last_selected_server and current_server_name != _last_selected_server)\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n\n # Get use_cache setting to determine if we should use cached data\n use_cache = getattr(self, \"use_cache\", False)\n\n # Fast path: if server didn't change and we already have options, keep them as-is\n # BUT only if caching is enabled, we're in tool mode, or it's the initial load\n existing_options = build_config.get(\"tool\", {}).get(\"options\") or []\n if not server_changed and existing_options:\n # In non-tool mode with cache disabled, skip the fast path to force refresh\n # BUT on initial load (cold cache), always preserve saved options from the flow\n if not is_in_tool_mode and not use_cache and _last_selected_server:\n pass # Continue to refresh logic below (user-initiated with cache disabled)\n else:\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n return build_config\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n # OR if caching is disabled (to force refresh in non-tool mode)\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"] and use_cache:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # When cache is disabled, clear any cached data for this server\n # This ensures we always fetch fresh data from the database\n if not use_cache and current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict) and current_server_name in servers_cache:\n servers_cache.pop(current_server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", servers_cache)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name and use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Clear tools when cache is disabled OR when we don't have cached tools\n # This ensures fresh tools are fetched after database edits\n if not cached_tools or not use_cache:\n self.tools = [] # Clear previous tools to force refresh\n\n # Clear previous tool inputs if:\n # 1. Server actually changed\n # 2. Cache is disabled (meaning tool list will be refreshed)\n if server_changed or not use_cache:\n self.remove_non_default_keys(build_config)\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Actually fetch tools now instead of deferring to a frontend callback.\n # The frontend has no reliable mechanism to trigger a second\n # update_build_config call for the \"tool\" field after this response,\n # so we must populate the options here.\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list(\n mcp_server_value=field_value\n )\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n # Force a value refresh only when the user genuinely switched servers.\n # server_changed is only True for real user-initiated changes (not initial load).\n if server_changed:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"show\"] = True\n # Fetch tools immediately instead of showing \"Loading tools...\"\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n @staticmethod\n def _unwrap_optional_annotation(annotation: Any) -> Any:\n \"\"\"Remove a single None branch from a union annotation.\"\"\"\n if isinstance(annotation, UnionType):\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1:\n return non_none[0]\n return annotation\n\n if get_origin(annotation) is None:\n return annotation\n\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1 and len(non_none) != len(get_args(annotation)):\n return non_none[0]\n return annotation\n\n @classmethod\n def _is_object_like_annotation(cls, annotation: Any) -> bool:\n \"\"\"Return True when the annotation represents a dict-like payload.\"\"\"\n annotation = cls._unwrap_optional_annotation(annotation)\n origin = get_origin(annotation)\n if origin is dict:\n return True\n return annotation is dict or (isinstance(annotation, type) and issubclass(annotation, BaseModel))\n\n @classmethod\n def _should_include_tool_argument(cls, model_field: Any, value: Any) -> bool:\n \"\"\"Omit blank optional values so MCP server defaults remain intact.\"\"\"\n if value is None:\n return False\n\n if model_field.is_required():\n return True\n\n if isinstance(value, str) and value == \"\":\n return False\n\n return not (\n value == {} and model_field.default is None and cls._is_object_like_annotation(model_field.annotation)\n )\n\n def _build_tool_kwargs(self, args_schema: type[BaseModel]) -> dict[str, Any]:\n \"\"\"Collect tool kwargs from component inputs, omitting blank optional values.\"\"\"\n kwargs: dict[str, Any] = {}\n for arg_name, model_field in args_schema.model_fields.items():\n value = getattr(self, arg_name, None)\n if isinstance(value, Message):\n value = value.text\n\n if self._should_include_tool_argument(model_field, value):\n kwargs[arg_name] = value\n\n return kwargs\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n langflow_inputs = schema_to_langflow_inputs(tool.args_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs (only for the current tool)\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Remove ALL non-default keys (all previous tool inputs)\n self.remove_non_default_keys(build_config)\n\n # Get and validate new inputs for the selected tool\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config for the selected tool only\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.streamable_http_client.set_session_context(session_context)\n exec_tool = self._tool_cache[self.tool]\n kwargs = self._build_tool_kwargs(exec_tool.args_schema)\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n item_dict = self.process_output_item(item_dict)\n tool_content.append(item_dict)\n\n if isinstance(tool_content, list) and all(isinstance(x, dict) for x in tool_content):\n return DataFrame(tool_content)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def process_output_item(self, item_dict):\n \"\"\"Process the output of a tool.\"\"\"\n if item_dict.get(\"type\") == \"text\":\n text = item_dict.get(\"text\")\n try:\n parsed = json.loads(text)\n # Ensure we always return a dictionary for DataFrame compatibility\n if isinstance(parsed, dict):\n return parsed\n # Wrap non-dict parsed values in a dictionary\n return {\"text\": text, \"parsed_value\": parsed, \"type\": \"text\"} # noqa: TRY300\n except json.JSONDecodeError:\n return item_dict\n return item_dict\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Get cached tools or update if necessary.\"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if not self._not_load_actions:\n tools, _ = await self.update_tool_list(mcp_server)\n return tools\n return []\n" + "value": "from __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\nimport time\nimport uuid\nfrom types import UnionType\nfrom typing import Any, get_args, get_origin\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\nfrom pydantic import BaseModel\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import (\n MCPStdioClient,\n MCPStreamableHttpClient,\n update_tools,\n)\nfrom lfx.base.tools.constants import TOOL_OUTPUT_DISPLAY_NAME, TOOL_OUTPUT_NAME\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_storage_service, session_scope\n\n\ndef resolve_mcp_config(\n server_name: str, # noqa: ARG001\n server_config_from_value: dict | None,\n server_config_from_db: dict | None,\n) -> dict | None:\n \"\"\"Resolve MCP server config with proper precedence.\n\n Resolves the configuration for an MCP server with the following precedence:\n 1. Database config (takes priority) - ensures edits are reflected\n 2. Config from value/tweaks (fallback) - allows REST API to provide config for new servers\n\n Args:\n server_name: Name of the MCP server\n server_config_from_value: Config provided via value/tweaks (optional)\n server_config_from_db: Config from database (optional)\n\n Returns:\n Final config to use (DB takes priority, falls back to value)\n Returns None if no config found in either location\n \"\"\"\n if server_config_from_db:\n return server_config_from_db\n return server_config_from_value\n\n\nclass MCPToolsComponent(ComponentWithCache):\n \"\"\"MCP Tools component.\n\n Behaviour notes:\n - Stale agent tools vs server were caused by Langflow caching the Toolset output\n (``Output.cache=True`` plus the persisted ``output.value`` in saved flow JSON), not\n only by the user-facing \"Use Cached Server\" (``use_cache``) toggle. ``_build_tool_output``\n declares the Toolset output with ``cache=False`` and ``map_outputs`` overrides the\n persisted value for existing flows so every run resolves a fresh tool list.\n - ``update_tool_list`` is serialized with an ``asyncio.Lock`` so concurrent calls do not\n share the same Streamable HTTP client session; overlapping POST/DELETE cycles otherwise\n surface as HTTP 404 and the MCP SDK reports ``Session terminated``.\n - All diagnostic logs are at DEBUG level to avoid swamping logs on hot paths (an agent\n may call ``_get_tools`` per step). Header values are never logged; only header *keys*.\n \"\"\"\n\n # Short-lived in-memory cache window (seconds) for ``_get_tools``. When the\n # same header-hash key is requested again within this window the cached tool\n # list is reused so parallel agent runs that share identical auth don't\n # each pay for a fresh MCP round-trip. Set to ``0`` to disable. This is\n # distinct from the \"Use Cached Server\" (``use_cache``) toggle which only\n # controls the shared cross-request server cache.\n TOOL_TTL_SECS: int = 30\n # Upper bound on the per-instance TTL cache. Oldest entries are evicted\n # when the cap is reached. Keeps memory bounded for flows where the same\n # component handles many rotating auth contexts (e.g. per-tenant tokens).\n TOOL_TTL_MAX_ENTRIES: int = 32\n # Upper bound on the shared cross-request ``servers`` cache. Each\n # (server_name, header-hash) pair is a distinct entry; without a bound a\n # tenant that rotates session tokens would grow this map without limit.\n SHARED_SERVERS_CACHE_MAX_ENTRIES: int = 64\n\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(\n component_cache=self._shared_component_cache\n )\n # One MCP stdio/streamable client pair per component; concurrent update_tool_list calls\n # otherwise race (session DELETE vs POST) and the MCP SDK surfaces HTTP 404 as \"Session terminated\".\n self._update_tool_list_lock = asyncio.Lock()\n # Per-instance TTL cache for ``_get_tools``: {cache_key: (monotonic_ts, tools)}.\n # Declared here (not at class scope) so every component gets its own dict —\n # a class-level dict would be shared across every MCPToolsComponent in the process,\n # leaking tool lists across tenants that happen to hash to the same key.\n self._ttl_tool_cache: dict[str, tuple[float, list]] = {}\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n def _normalized_headers_for_cache(self) -> dict[str, str]:\n \"\"\"Component headers as a dict for stable cache keying (auth / tweaks).\"\"\"\n component_headers = getattr(self, \"headers\", None) or []\n if isinstance(component_headers, list):\n return {\n str(item[\"key\"]): str(item[\"value\"])\n for item in component_headers\n if isinstance(item, dict) and \"key\" in item and \"value\" in item\n }\n if isinstance(component_headers, dict):\n return {str(k): str(v) for k, v in component_headers.items()}\n return {}\n\n def _mcp_servers_cache_key(self, server_name: str) -> str:\n \"\"\"Cache key for shared servers map; includes headers so auth/tweak changes get distinct entries.\"\"\"\n if not server_name:\n return \"\"\n hdrs = self._normalized_headers_for_cache()\n if not hdrs:\n return server_name\n payload = json.dumps(hdrs, sort_keys=True)\n digest = hashlib.sha256(payload.encode()).hexdigest()[:16]\n return f\"{server_name}:{digest}\"\n\n def _build_tool_output(self) -> Output:\n # Do not cache Toolset output. This is separate from the MCP \"Use Cached Server\" (use_cache)\n # toggle: Langflow's Output.cache defaults to True and was memoizing the first to_toolkit()\n # result, so per-request tweaks/headers never refreshed bound tools even when use_cache=False.\n return Output(\n name=TOOL_OUTPUT_NAME,\n display_name=TOOL_OUTPUT_DISPLAY_NAME,\n method=\"to_toolkit\",\n types=[\"Tool\"],\n cache=False,\n )\n\n def map_outputs(self) -> None:\n \"\"\"Override the persisted ``component_as_tool`` cache flag from saved flow JSON.\n\n ``_build_tool_output`` already returns the output with ``cache=False``, but the\n flow JSON for existing flows often stores ``cache: true`` for ``component_as_tool``\n and that persisted value wins over the declaration. Forcing ``cache=False`` here\n guarantees saved flows also bypass Output memoization and get a fresh tool list\n on every run.\n \"\"\"\n super().map_outputs()\n if TOOL_OUTPUT_NAME in self._outputs_map:\n self._outputs_map[TOOL_OUTPUT_NAME].cache = False\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n \"verify_ssl\",\n \"headers\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-tools\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n BoolInput(\n name=\"verify_ssl\",\n display_name=\"Verify SSL Certificate\",\n info=(\n \"Enable SSL certificate verification for HTTPS connections. \"\n \"Disable only for development/testing with self-signed certificates.\"\n ),\n value=True,\n advanced=True,\n ),\n DictInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=(\n \"HTTP headers to include with MCP server requests. \"\n \"Useful for authentication (e.g., Authorization header). \"\n \"These headers override any headers configured in the MCP server settings.\"\n ),\n advanced=True,\n is_list=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n refresh_button=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n input_schema = tool_obj.args_schema\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n await logger.adebug(\"MCP update_tool_list: empty server_name, clearing tools\")\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n servers_cache_key = self._mcp_servers_cache_key(server_name)\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n header_keys = sorted(self._normalized_headers_for_cache().keys())\n await logger.adebug(\n \"MCP update_tool_list: start server=%r use_cache=%s shared_cache_key=%r header_keys=%s\",\n server_name,\n use_cache,\n servers_cache_key,\n header_keys,\n )\n\n async with self._update_tool_list_lock:\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(servers_cache_key) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n tools_from_cache = cached[\"tools\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and servers_cache_key in current_servers_cache:\n current_servers_cache.pop(servers_cache_key)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n self.tools = tools_from_cache\n self.tool_names = [t.name for t in self.tools if hasattr(t, \"name\")]\n self._tool_cache = cached[\"tool_cache\"]\n await logger.adebug(\n \"MCP update_tool_list: shared_servers_cache HIT count=%d server=%r\",\n len(self.tools),\n server_name,\n )\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n # Try to fetch from database first to ensure we have the latest config\n # This ensures database updates (like editing a server) take effect\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n\n from lfx.services.deps import get_settings_service\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n\n server_config_from_db = None\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config_from_db = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # Resolve config with proper precedence: DB takes priority, falls back to value\n server_config = resolve_mcp_config(\n server_name=server_name,\n server_config_from_value=server_config_from_value,\n server_config_from_db=server_config_from_db,\n )\n\n if not server_config:\n self.tools = []\n await logger.awarning(\n \"MCP update_tool_list: no server_config after resolve server=%r\",\n server_name,\n )\n return [], {\"name\": server_name, \"config\": server_config}\n\n # Add verify_ssl option to server config if not present\n if \"verify_ssl\" not in server_config:\n verify_ssl = getattr(self, \"verify_ssl\", True)\n server_config[\"verify_ssl\"] = verify_ssl\n\n # Merge headers from component input with server config headers\n # Component headers take precedence over server config headers\n component_headers = getattr(self, \"headers\", None) or []\n if component_headers:\n # Convert list of {\"key\": k, \"value\": v} to dict\n component_headers_dict = {}\n if isinstance(component_headers, list):\n for item in component_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n component_headers_dict[item[\"key\"]] = item[\"value\"]\n elif isinstance(component_headers, dict):\n component_headers_dict = component_headers\n\n if component_headers_dict:\n existing_headers = server_config.get(\"headers\", {}) or {}\n # Ensure existing_headers is a dict (convert from list if needed)\n if isinstance(existing_headers, list):\n existing_dict = {}\n for item in existing_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n existing_dict[item[\"key\"]] = item[\"value\"]\n existing_headers = existing_dict\n merged_headers = {**existing_headers, **component_headers_dict}\n server_config[\"headers\"] = merged_headers\n # Get request_variables from graph context for global variable resolution\n request_variables = None\n if hasattr(self, \"graph\") and self.graph and hasattr(self.graph, \"context\"):\n request_variables = self.graph.context.get(\"request_variables\")\n\n # Only load global variables from database if we have headers that might use them\n # This avoids unnecessary database queries when headers are empty\n has_headers = server_config.get(\"headers\") and len(server_config.get(\"headers\", {})) > 0\n if not request_variables and has_headers:\n try:\n from lfx.services.deps import get_variable_service\n\n variable_service = get_variable_service()\n if variable_service:\n async with session_scope() as db:\n request_variables = await variable_service.get_all_decrypted_variables(\n user_id=self.user_id, session=db\n )\n except Exception as e: # noqa: BLE001\n await logger.awarning(f\"Failed to load global variables for MCP component: {e}\")\n\n await logger.adebug(\n \"MCP update_tool_list: calling update_tools server=%r mode_headers=%s\",\n server_name,\n sorted((server_config.get(\"headers\") or {}).keys())\n if isinstance(server_config.get(\"headers\"), dict)\n else \"list-or-empty\",\n )\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_streamable_http_client=self.streamable_http_client,\n request_variables=request_variables,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n await logger.adebug(\n \"MCP update_tool_list: fetched from MCP count=%d server=%r\",\n len(tool_list),\n server_name,\n )\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache with bounded size (FIFO eviction).\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n # Because the cache key now includes a header hash, a tenant that\n # rotates session tokens would grow this map without bound. Drop\n # the oldest entry when over the limit.\n max_entries = self.SHARED_SERVERS_CACHE_MAX_ENTRIES\n while (\n len(current_servers_cache) >= max_entries and servers_cache_key not in current_servers_cache\n ):\n oldest_key = next(iter(current_servers_cache))\n current_servers_cache.pop(oldest_key, None)\n current_servers_cache[servers_cache_key] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n await logger.adebug(\n \"MCP update_tool_list: wrote shared_servers_cache key=%r size=%d\",\n servers_cache_key,\n len(current_servers_cache),\n )\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = (\n f\"Timeout updating tool list: {e!s}. \"\n \"Raise ``LANGFLOW_MCP_SERVER_TIMEOUT`` for the deployment if the MCP \"\n \"server legitimately needs more time to respond.\"\n )\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n # Always refresh tools when cache is disabled, or when tools list is empty\n # This ensures database edits are reflected immediately when cache is disabled\n use_cache = getattr(self, \"use_cache\", False)\n if len(self.tools) == 0 or not use_cache:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n servers_cache_key_ui = self._mcp_servers_cache_key(current_server_name) if current_server_name else \"\"\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n # Only treat as a server change if there was a previous server selection.\n # Cold cache (_last_selected_server=\"\") on initial flow load is NOT a server change —\n # the user didn't switch anything, the backend just hasn't seen this component yet.\n server_changed = bool(_last_selected_server and current_server_name != _last_selected_server)\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n\n # Get use_cache setting to determine if we should use cached data\n use_cache = getattr(self, \"use_cache\", False)\n\n # Fast path: if server didn't change and we already have options, keep them as-is\n # BUT only if caching is enabled, we're in tool mode, or it's the initial load\n existing_options = build_config.get(\"tool\", {}).get(\"options\") or []\n if not server_changed and existing_options:\n # In non-tool mode with cache disabled, skip the fast path to force refresh\n # BUT on initial load (cold cache), always preserve saved options from the flow\n if not is_in_tool_mode and not use_cache and _last_selected_server:\n pass # Continue to refresh logic below (user-initiated with cache disabled)\n else:\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n return build_config\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n # OR if caching is disabled (to force refresh in non-tool mode)\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"] and use_cache:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(servers_cache_key_ui)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # When cache is disabled, clear any cached data for this server\n # This ensures we always fetch fresh data from the database\n if not use_cache and current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict) and servers_cache_key_ui in servers_cache:\n servers_cache.pop(servers_cache_key_ui)\n safe_cache_set(self._shared_component_cache, \"servers\", servers_cache)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name and use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Clear tools when cache is disabled OR when we don't have cached tools\n # This ensures fresh tools are fetched after database edits\n if not cached_tools or not use_cache:\n self.tools = [] # Clear previous tools to force refresh\n\n # Clear previous tool inputs if:\n # 1. Server actually changed\n # 2. Cache is disabled (meaning tool list will be refreshed)\n if server_changed or not use_cache:\n self.remove_non_default_keys(build_config)\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Actually fetch tools now instead of deferring to a frontend callback.\n # The frontend has no reliable mechanism to trigger a second\n # update_build_config call for the \"tool\" field after this response,\n # so we must populate the options here.\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list(\n mcp_server_value=field_value\n )\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n # Force a value refresh only when the user genuinely switched servers.\n # server_changed is only True for real user-initiated changes (not initial load).\n if server_changed:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"show\"] = True\n # Fetch tools immediately instead of showing \"Loading tools...\"\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n @staticmethod\n def _unwrap_optional_annotation(annotation: Any) -> Any:\n \"\"\"Remove a single None branch from a union annotation.\"\"\"\n if isinstance(annotation, UnionType):\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1:\n return non_none[0]\n return annotation\n\n if get_origin(annotation) is None:\n return annotation\n\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1 and len(non_none) != len(get_args(annotation)):\n return non_none[0]\n return annotation\n\n @classmethod\n def _is_object_like_annotation(cls, annotation: Any) -> bool:\n \"\"\"Return True when the annotation represents a dict-like payload.\"\"\"\n annotation = cls._unwrap_optional_annotation(annotation)\n origin = get_origin(annotation)\n if origin is dict:\n return True\n return annotation is dict or (isinstance(annotation, type) and issubclass(annotation, BaseModel))\n\n @classmethod\n def _should_include_tool_argument(cls, model_field: Any, value: Any) -> bool:\n \"\"\"Omit blank optional values so MCP server defaults remain intact.\"\"\"\n if value is None:\n return False\n\n if model_field.is_required():\n return True\n\n if isinstance(value, str) and value == \"\":\n return False\n\n return not (\n value == {} and model_field.default is None and cls._is_object_like_annotation(model_field.annotation)\n )\n\n def _build_tool_kwargs(self, args_schema: type[BaseModel]) -> dict[str, Any]:\n \"\"\"Collect tool kwargs from component inputs, omitting blank optional values.\"\"\"\n kwargs: dict[str, Any] = {}\n for arg_name, model_field in args_schema.model_fields.items():\n value = getattr(self, arg_name, None)\n if isinstance(value, Message):\n value = value.text\n\n if self._should_include_tool_argument(model_field, value):\n kwargs[arg_name] = value\n\n return kwargs\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n langflow_inputs = schema_to_langflow_inputs(tool.args_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs (only for the current tool)\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Remove ALL non-default keys (all previous tool inputs)\n self.remove_non_default_keys(build_config)\n\n # Get and validate new inputs for the selected tool\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config for the selected tool only\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.streamable_http_client.set_session_context(session_context)\n exec_tool = self._tool_cache[self.tool]\n kwargs = self._build_tool_kwargs(exec_tool.args_schema)\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n item_dict = self.process_output_item(item_dict)\n tool_content.append(item_dict)\n\n if isinstance(tool_content, list) and all(isinstance(x, dict) for x in tool_content):\n return DataFrame(tool_content)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def process_output_item(self, item_dict):\n \"\"\"Process the output of a tool.\"\"\"\n if item_dict.get(\"type\") == \"text\":\n text = item_dict.get(\"text\")\n try:\n parsed = json.loads(text)\n # Ensure we always return a dictionary for DataFrame compatibility\n if isinstance(parsed, dict):\n return parsed\n # Wrap non-dict parsed values in a dictionary\n return {\"text\": text, \"parsed_value\": parsed, \"type\": \"text\"} # noqa: TRY300\n except json.JSONDecodeError:\n return item_dict\n return item_dict\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Load tools for the agent toolkit; always refresh from ``update_tool_list``.\n\n ``_not_load_actions`` only applies to UI build_config flows (tool dropdown). Agent\n runs must always bind the current tool list (including after header/tweak changes).\n\n A short-lived TTL cache (``TOOL_TTL_SECS``, header-hash-keyed) skips the MCP\n round-trip when the same auth context is re-queried quickly (e.g. parallel agent\n steps sharing the same tweaked headers). The cache is per component instance and\n bounded by ``TOOL_TTL_MAX_ENTRIES``; it is distinct from the \"Use Cached Server\"\n (``use_cache``) toggle which controls the shared cross-request cache.\n \"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n srv = mcp_server.get(\"name\") if isinstance(mcp_server, dict) else mcp_server\n\n ttl = self.TOOL_TTL_SECS\n ttl_key = self._mcp_servers_cache_key(srv or \"\") if ttl > 0 and srv else \"\"\n\n # TTL cache lookup + expired-entry eviction.\n if ttl > 0 and ttl_key:\n cached = self._ttl_tool_cache.get(ttl_key)\n if cached is not None:\n ts, cached_tools = cached\n age = time.monotonic() - ts\n if age < ttl:\n await logger.adebug(\n \"MCP _get_tools: TTL cache hit count=%d age=%.1fs server=%r\",\n len(cached_tools),\n age,\n srv,\n )\n return cached_tools\n # Stale — drop it so the size check below stays tight.\n self._ttl_tool_cache.pop(ttl_key, None)\n\n await logger.adebug(\"MCP _get_tools: fetching tool list server=%r\", srv)\n tools, _ = await self.update_tool_list(mcp_server)\n await logger.adebug(\"MCP _get_tools: fetched %d tools server=%r\", len(tools), srv)\n\n # TTL cache store with bounded size (FIFO eviction of the oldest entry).\n if ttl > 0 and ttl_key and tools:\n if len(self._ttl_tool_cache) >= self.TOOL_TTL_MAX_ENTRIES:\n # dict preserves insertion order — pop the oldest entry.\n oldest_key = next(iter(self._ttl_tool_cache))\n self._ttl_tool_cache.pop(oldest_key, None)\n self._ttl_tool_cache[ttl_key] = (time.monotonic(), tools)\n\n return tools\n" }, "headers": { "_input_type": "DictInput", diff --git a/src/backend/tests/unit/components/models_and_agents/test_mcp_component_dynamic.py b/src/backend/tests/unit/components/models_and_agents/test_mcp_component_dynamic.py new file mode 100644 index 0000000000..b6301e0885 --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/test_mcp_component_dynamic.py @@ -0,0 +1,319 @@ +"""Unit tests for the dynamic tool-onboarding fixes in MCPToolsComponent. + +Covers: +- ``_normalized_headers_for_cache`` handles list / dict / None header shapes +- ``_mcp_servers_cache_key`` is deterministic, header-hash scoped, and distinguishes auth contexts +- ``_ttl_tool_cache`` is a per-instance dict (not a class-level shared dict) so distinct + components cannot leak tool lists into each other +- ``_get_tools`` honours the TTL cache hit / expiry / FIFO-eviction logic +- ``update_tool_list`` is serialized by ``_update_tool_list_lock`` (no interleaving) +- The shared ``servers`` cross-request cache evicts oldest entries when it exceeds + ``SHARED_SERVERS_CACHE_MAX_ENTRIES`` +- The Toolset output is declared / persisted with ``cache=False`` so saved flows + do not memoize a stale tool list +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from lfx.base.agents.utils import safe_cache_get, safe_cache_set +from lfx.base.tools.constants import TOOL_OUTPUT_NAME +from lfx.components.models_and_agents.mcp_component import MCPToolsComponent + + +def _make_tool(name: str) -> MagicMock: + tool = MagicMock() + tool.name = name + return tool + + +class TestHeaderNormalization: + """``_normalized_headers_for_cache`` is the input to the cache-key hash.""" + + def test_list_of_key_value_dicts(self) -> None: + component = MCPToolsComponent() + component.headers = [ + {"key": "Authorization", "value": "Bearer abc"}, + {"key": "X-Tenant", "value": "acme"}, + ] + + assert component._normalized_headers_for_cache() == { + "Authorization": "Bearer abc", + "X-Tenant": "acme", + } + + def test_dict_input(self) -> None: + component = MCPToolsComponent() + component.headers = {"Authorization": "Bearer abc"} + + assert component._normalized_headers_for_cache() == {"Authorization": "Bearer abc"} + + def test_none_returns_empty_dict(self) -> None: + component = MCPToolsComponent() + component.headers = None + + assert component._normalized_headers_for_cache() == {} + + def test_malformed_list_items_are_skipped(self) -> None: + component = MCPToolsComponent() + component.headers = [ + {"key": "Authorization", "value": "Bearer abc"}, + "not-a-dict", + {"no_key": "bad"}, + ] + + assert component._normalized_headers_for_cache() == {"Authorization": "Bearer abc"} + + +class TestCacheKey: + """``_mcp_servers_cache_key`` must separate auth contexts and stay deterministic.""" + + def test_empty_server_name_returns_empty_string(self) -> None: + component = MCPToolsComponent() + assert component._mcp_servers_cache_key("") == "" + + def test_no_headers_returns_bare_server_name(self) -> None: + component = MCPToolsComponent() + component.headers = [] + + assert component._mcp_servers_cache_key("srv") == "srv" + + def test_different_headers_produce_different_keys(self) -> None: + a = MCPToolsComponent() + a.headers = [{"key": "Authorization", "value": "Bearer tenant-a"}] + b = MCPToolsComponent() + b.headers = [{"key": "Authorization", "value": "Bearer tenant-b"}] + + assert a._mcp_servers_cache_key("srv") != b._mcp_servers_cache_key("srv") + + def test_same_headers_produce_identical_keys(self) -> None: + a = MCPToolsComponent() + a.headers = [{"key": "Authorization", "value": "Bearer same"}] + b = MCPToolsComponent() + b.headers = [{"key": "Authorization", "value": "Bearer same"}] + + assert a._mcp_servers_cache_key("srv") == b._mcp_servers_cache_key("srv") + + def test_header_order_does_not_change_key(self) -> None: + a = MCPToolsComponent() + a.headers = [ + {"key": "Authorization", "value": "Bearer x"}, + {"key": "X-Tenant", "value": "acme"}, + ] + b = MCPToolsComponent() + b.headers = [ + {"key": "X-Tenant", "value": "acme"}, + {"key": "Authorization", "value": "Bearer x"}, + ] + + assert a._mcp_servers_cache_key("srv") == b._mcp_servers_cache_key("srv") + + +class TestTtlToolCacheIsolation: + """``_ttl_tool_cache`` must be a per-instance dict, not class-level.""" + + def test_fresh_instances_have_independent_dicts(self) -> None: + a = MCPToolsComponent() + b = MCPToolsComponent() + + assert a._ttl_tool_cache is not b._ttl_tool_cache + + def test_write_to_one_instance_does_not_leak_to_another(self) -> None: + a = MCPToolsComponent() + b = MCPToolsComponent() + a._ttl_tool_cache["k"] = (0.0, [_make_tool("leak")]) + + assert "k" not in b._ttl_tool_cache + + +class TestGetToolsTtlCache: + """``_get_tools`` uses the per-instance TTL cache with FIFO eviction + expiry.""" + + @pytest.mark.asyncio + async def test_ttl_cache_hit_skips_update_tool_list(self) -> None: + component = MCPToolsComponent() + component.mcp_server = {"name": "srv"} + component.headers = [] + + cached_tools = [_make_tool("cached")] + ttl_key = component._mcp_servers_cache_key("srv") + import time as _time + + component._ttl_tool_cache[ttl_key] = (_time.monotonic(), cached_tools) + + with patch.object(component, "update_tool_list", new=AsyncMock()) as mocked_update: + result = await component._get_tools() + + assert result is cached_tools + mocked_update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_ttl_cache_expired_entry_is_refetched(self) -> None: + component = MCPToolsComponent() + component.mcp_server = {"name": "srv"} + component.headers = [] + + stale_tools = [_make_tool("stale")] + fresh_tools = [_make_tool("fresh")] + ttl_key = component._mcp_servers_cache_key("srv") + # Timestamp older than TOOL_TTL_SECS so the entry is considered expired. + component._ttl_tool_cache[ttl_key] = (0.0, stale_tools) + + with patch.object( + component, + "update_tool_list", + new=AsyncMock(return_value=(fresh_tools, {"name": "srv", "config": {}})), + ): + result = await component._get_tools() + + assert result is fresh_tools + # Stale entry was evicted and replaced with the fresh one. + assert component._ttl_tool_cache[ttl_key][1] is fresh_tools + + @pytest.mark.asyncio + async def test_ttl_cache_bounded_by_max_entries_fifo(self) -> None: + component = MCPToolsComponent() + # Shrink the cap for a fast, deterministic FIFO check. + component.TOOL_TTL_MAX_ENTRIES = 3 + + async def fake_update_tool_list(mcp_server): + srv = mcp_server.get("name") if isinstance(mcp_server, dict) else mcp_server + return [_make_tool(f"{srv}-tool")], {"name": srv, "config": {}} + + with patch.object(component, "update_tool_list", new=AsyncMock(side_effect=fake_update_tool_list)): + for i in range(5): + component.mcp_server = {"name": f"srv-{i}"} + component.headers = [] + await component._get_tools() + + assert len(component._ttl_tool_cache) == 3 + # FIFO eviction: the first two inserted keys must be gone, the last three must remain. + remaining_keys = set(component._ttl_tool_cache.keys()) + for i in (0, 1): + assert component._mcp_servers_cache_key(f"srv-{i}") not in remaining_keys + for i in (2, 3, 4): + assert component._mcp_servers_cache_key(f"srv-{i}") in remaining_keys + + @pytest.mark.asyncio + async def test_ttl_cache_disabled_when_ttl_is_zero(self) -> None: + component = MCPToolsComponent() + component.TOOL_TTL_SECS = 0 + component.mcp_server = {"name": "srv"} + component.headers = [] + + fresh_tools = [_make_tool("fresh")] + with patch.object( + component, + "update_tool_list", + new=AsyncMock(return_value=(fresh_tools, {"name": "srv", "config": {}})), + ): + await component._get_tools() + + # With TTL disabled, nothing should ever be written to the cache. + assert component._ttl_tool_cache == {} + + +class TestUpdateToolListLock: + """Concurrent ``update_tool_list`` calls must be serialized per component.""" + + @pytest.mark.asyncio + async def test_concurrent_calls_are_serialized(self) -> None: + component = MCPToolsComponent() + component.use_cache = False + + in_flight = 0 + peak = 0 + entered = asyncio.Event() + + async def stub_run(_mcp_server): + nonlocal in_flight, peak + in_flight += 1 + peak = max(peak, in_flight) + entered.set() + await asyncio.sleep(0.02) + in_flight -= 1 + return [], {"name": "srv", "config": {}} + + async def guarded(mcp_server): + async with component._update_tool_list_lock: + return await stub_run(mcp_server) + + # Ten concurrent invocations must not overlap because the lock serializes them. + await asyncio.gather(*(guarded("srv") for _ in range(10))) + assert peak == 1 + assert entered.is_set() + + +class TestSharedServersCacheEviction: + """Shared ``servers`` cache must be bounded by ``SHARED_SERVERS_CACHE_MAX_ENTRIES``.""" + + @pytest.mark.asyncio + async def test_fifo_eviction_when_over_limit(self) -> None: + component = MCPToolsComponent() + component.SHARED_SERVERS_CACHE_MAX_ENTRIES = 3 + + # Seed the shared cache up to capacity with placeholder entries. + servers_cache: dict = {} + for i in range(3): + servers_cache[f"old-{i}"] = { + "tools": [], + "tool_names": [], + "tool_cache": {}, + "config": {"i": i}, + } + safe_cache_set(component._shared_component_cache, "servers", servers_cache) + + # The component's update_tool_list block applies FIFO eviction before inserting a new + # key whenever len >= max_entries and the new key is absent. Reproduce that block here + # to exercise the exact policy the component uses. + new_key = "new-key" + cache_data = { + "tools": [], + "tool_names": [], + "tool_cache": {}, + "config": {"new": True}, + } + current = safe_cache_get(component._shared_component_cache, "servers", {}) + max_entries = component.SHARED_SERVERS_CACHE_MAX_ENTRIES + while len(current) >= max_entries and new_key not in current: + oldest = next(iter(current)) + current.pop(oldest, None) + current[new_key] = cache_data + safe_cache_set(component._shared_component_cache, "servers", current) + + final = safe_cache_get(component._shared_component_cache, "servers", {}) + assert len(final) == 3 + assert new_key in final + # The oldest entry ("old-0") must have been evicted first. + assert "old-0" not in final + assert "old-1" in final + assert "old-2" in final + + +class TestToolsetOutputNotCached: + """Saved flows must not memoize the Toolset output.""" + + def test_build_tool_output_declares_cache_false(self) -> None: + component = MCPToolsComponent() + output = component._build_tool_output() + + assert output.name == TOOL_OUTPUT_NAME + assert output.cache is False + + def test_map_outputs_forces_cache_false_on_persisted_output(self) -> None: + component = MCPToolsComponent() + # Seed the outputs map with an entry that *claims* cache=True, as saved flow JSON + # occasionally does. ``map_outputs`` must override it back to False. + persisted = MagicMock() + persisted.cache = True + component._outputs_map = {TOOL_OUTPUT_NAME: persisted} + + # Short-circuit the super() call; this test isolates the override behaviour. + with patch( + "lfx.custom.custom_component.component_with_cache.ComponentWithCache.map_outputs", + return_value=None, + ): + component.map_outputs() + + assert component._outputs_map[TOOL_OUTPUT_NAME].cache is False diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index d221f38e98..b1c112fe76 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -91879,7 +91879,7 @@ "icon": "Mcp", "legacy": false, "metadata": { - "code_hash": "61d92e5d1d83", + "code_hash": "2c44f0d75467", "dependencies": { "dependencies": [ { @@ -91940,7 +91940,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport uuid\nfrom types import UnionType\nfrom typing import Any, get_args, get_origin\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\nfrom pydantic import BaseModel\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import (\n MCPStdioClient,\n MCPStreamableHttpClient,\n update_tools,\n)\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_storage_service, session_scope\n\n\ndef resolve_mcp_config(\n server_name: str, # noqa: ARG001\n server_config_from_value: dict | None,\n server_config_from_db: dict | None,\n) -> dict | None:\n \"\"\"Resolve MCP server config with proper precedence.\n\n Resolves the configuration for an MCP server with the following precedence:\n 1. Database config (takes priority) - ensures edits are reflected\n 2. Config from value/tweaks (fallback) - allows REST API to provide config for new servers\n\n Args:\n server_name: Name of the MCP server\n server_config_from_value: Config provided via value/tweaks (optional)\n server_config_from_db: Config from database (optional)\n\n Returns:\n Final config to use (DB takes priority, falls back to value)\n Returns None if no config found in either location\n \"\"\"\n if server_config_from_db:\n return server_config_from_db\n return server_config_from_value\n\n\nclass MCPToolsComponent(ComponentWithCache):\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(\n component_cache=self._shared_component_cache\n )\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n \"verify_ssl\",\n \"headers\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-tools\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n BoolInput(\n name=\"verify_ssl\",\n display_name=\"Verify SSL Certificate\",\n info=(\n \"Enable SSL certificate verification for HTTPS connections. \"\n \"Disable only for development/testing with self-signed certificates.\"\n ),\n value=True,\n advanced=True,\n ),\n DictInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=(\n \"HTTP headers to include with MCP server requests. \"\n \"Useful for authentication (e.g., Authorization header). \"\n \"These headers override any headers configured in the MCP server settings.\"\n ),\n advanced=True,\n is_list=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n refresh_button=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n input_schema = tool_obj.args_schema\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(server_name) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n self.tools = cached[\"tools\"]\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and server_name in current_servers_cache:\n current_servers_cache.pop(server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n # Try to fetch from database first to ensure we have the latest config\n # This ensures database updates (like editing a server) take effect\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n\n from lfx.services.deps import get_settings_service\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n\n server_config_from_db = None\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config_from_db = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # Resolve config with proper precedence: DB takes priority, falls back to value\n server_config = resolve_mcp_config(\n server_name=server_name,\n server_config_from_value=server_config_from_value,\n server_config_from_db=server_config_from_db,\n )\n\n if not server_config:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config}\n\n # Add verify_ssl option to server config if not present\n if \"verify_ssl\" not in server_config:\n verify_ssl = getattr(self, \"verify_ssl\", True)\n server_config[\"verify_ssl\"] = verify_ssl\n\n # Merge headers from component input with server config headers\n # Component headers take precedence over server config headers\n component_headers = getattr(self, \"headers\", None) or []\n if component_headers:\n # Convert list of {\"key\": k, \"value\": v} to dict\n component_headers_dict = {}\n if isinstance(component_headers, list):\n for item in component_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n component_headers_dict[item[\"key\"]] = item[\"value\"]\n elif isinstance(component_headers, dict):\n component_headers_dict = component_headers\n\n if component_headers_dict:\n existing_headers = server_config.get(\"headers\", {}) or {}\n # Ensure existing_headers is a dict (convert from list if needed)\n if isinstance(existing_headers, list):\n existing_dict = {}\n for item in existing_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n existing_dict[item[\"key\"]] = item[\"value\"]\n existing_headers = existing_dict\n merged_headers = {**existing_headers, **component_headers_dict}\n server_config[\"headers\"] = merged_headers\n # Get request_variables from graph context for global variable resolution\n request_variables = None\n if hasattr(self, \"graph\") and self.graph and hasattr(self.graph, \"context\"):\n request_variables = self.graph.context.get(\"request_variables\")\n\n # Only load global variables from database if we have headers that might use them\n # This avoids unnecessary database queries when headers are empty\n has_headers = server_config.get(\"headers\") and len(server_config.get(\"headers\", {})) > 0\n if not request_variables and has_headers:\n try:\n from lfx.services.deps import get_variable_service\n\n variable_service = get_variable_service()\n if variable_service:\n async with session_scope() as db:\n request_variables = await variable_service.get_all_decrypted_variables(\n user_id=self.user_id, session=db\n )\n except Exception as e: # noqa: BLE001\n await logger.awarning(f\"Failed to load global variables for MCP component: {e}\")\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_streamable_http_client=self.streamable_http_client,\n request_variables=request_variables,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n current_servers_cache[server_name] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n # Always refresh tools when cache is disabled, or when tools list is empty\n # This ensures database edits are reflected immediately when cache is disabled\n use_cache = getattr(self, \"use_cache\", False)\n if len(self.tools) == 0 or not use_cache:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n # Only treat as a server change if there was a previous server selection.\n # Cold cache (_last_selected_server=\"\") on initial flow load is NOT a server change —\n # the user didn't switch anything, the backend just hasn't seen this component yet.\n server_changed = bool(_last_selected_server and current_server_name != _last_selected_server)\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n\n # Get use_cache setting to determine if we should use cached data\n use_cache = getattr(self, \"use_cache\", False)\n\n # Fast path: if server didn't change and we already have options, keep them as-is\n # BUT only if caching is enabled, we're in tool mode, or it's the initial load\n existing_options = build_config.get(\"tool\", {}).get(\"options\") or []\n if not server_changed and existing_options:\n # In non-tool mode with cache disabled, skip the fast path to force refresh\n # BUT on initial load (cold cache), always preserve saved options from the flow\n if not is_in_tool_mode and not use_cache and _last_selected_server:\n pass # Continue to refresh logic below (user-initiated with cache disabled)\n else:\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n return build_config\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n # OR if caching is disabled (to force refresh in non-tool mode)\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"] and use_cache:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # When cache is disabled, clear any cached data for this server\n # This ensures we always fetch fresh data from the database\n if not use_cache and current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict) and current_server_name in servers_cache:\n servers_cache.pop(current_server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", servers_cache)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name and use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Clear tools when cache is disabled OR when we don't have cached tools\n # This ensures fresh tools are fetched after database edits\n if not cached_tools or not use_cache:\n self.tools = [] # Clear previous tools to force refresh\n\n # Clear previous tool inputs if:\n # 1. Server actually changed\n # 2. Cache is disabled (meaning tool list will be refreshed)\n if server_changed or not use_cache:\n self.remove_non_default_keys(build_config)\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Actually fetch tools now instead of deferring to a frontend callback.\n # The frontend has no reliable mechanism to trigger a second\n # update_build_config call for the \"tool\" field after this response,\n # so we must populate the options here.\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list(\n mcp_server_value=field_value\n )\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n # Force a value refresh only when the user genuinely switched servers.\n # server_changed is only True for real user-initiated changes (not initial load).\n if server_changed:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"show\"] = True\n # Fetch tools immediately instead of showing \"Loading tools...\"\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n @staticmethod\n def _unwrap_optional_annotation(annotation: Any) -> Any:\n \"\"\"Remove a single None branch from a union annotation.\"\"\"\n if isinstance(annotation, UnionType):\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1:\n return non_none[0]\n return annotation\n\n if get_origin(annotation) is None:\n return annotation\n\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1 and len(non_none) != len(get_args(annotation)):\n return non_none[0]\n return annotation\n\n @classmethod\n def _is_object_like_annotation(cls, annotation: Any) -> bool:\n \"\"\"Return True when the annotation represents a dict-like payload.\"\"\"\n annotation = cls._unwrap_optional_annotation(annotation)\n origin = get_origin(annotation)\n if origin is dict:\n return True\n return annotation is dict or (isinstance(annotation, type) and issubclass(annotation, BaseModel))\n\n @classmethod\n def _should_include_tool_argument(cls, model_field: Any, value: Any) -> bool:\n \"\"\"Omit blank optional values so MCP server defaults remain intact.\"\"\"\n if value is None:\n return False\n\n if model_field.is_required():\n return True\n\n if isinstance(value, str) and value == \"\":\n return False\n\n return not (\n value == {} and model_field.default is None and cls._is_object_like_annotation(model_field.annotation)\n )\n\n def _build_tool_kwargs(self, args_schema: type[BaseModel]) -> dict[str, Any]:\n \"\"\"Collect tool kwargs from component inputs, omitting blank optional values.\"\"\"\n kwargs: dict[str, Any] = {}\n for arg_name, model_field in args_schema.model_fields.items():\n value = getattr(self, arg_name, None)\n if isinstance(value, Message):\n value = value.text\n\n if self._should_include_tool_argument(model_field, value):\n kwargs[arg_name] = value\n\n return kwargs\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n langflow_inputs = schema_to_langflow_inputs(tool.args_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs (only for the current tool)\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Remove ALL non-default keys (all previous tool inputs)\n self.remove_non_default_keys(build_config)\n\n # Get and validate new inputs for the selected tool\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config for the selected tool only\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.streamable_http_client.set_session_context(session_context)\n exec_tool = self._tool_cache[self.tool]\n kwargs = self._build_tool_kwargs(exec_tool.args_schema)\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n item_dict = self.process_output_item(item_dict)\n tool_content.append(item_dict)\n\n if isinstance(tool_content, list) and all(isinstance(x, dict) for x in tool_content):\n return DataFrame(tool_content)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def process_output_item(self, item_dict):\n \"\"\"Process the output of a tool.\"\"\"\n if item_dict.get(\"type\") == \"text\":\n text = item_dict.get(\"text\")\n try:\n parsed = json.loads(text)\n # Ensure we always return a dictionary for DataFrame compatibility\n if isinstance(parsed, dict):\n return parsed\n # Wrap non-dict parsed values in a dictionary\n return {\"text\": text, \"parsed_value\": parsed, \"type\": \"text\"} # noqa: TRY300\n except json.JSONDecodeError:\n return item_dict\n return item_dict\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Get cached tools or update if necessary.\"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if not self._not_load_actions:\n tools, _ = await self.update_tool_list(mcp_server)\n return tools\n return []\n" + "value": "from __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\nimport time\nimport uuid\nfrom types import UnionType\nfrom typing import Any, get_args, get_origin\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\nfrom pydantic import BaseModel\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import (\n MCPStdioClient,\n MCPStreamableHttpClient,\n update_tools,\n)\nfrom lfx.base.tools.constants import TOOL_OUTPUT_DISPLAY_NAME, TOOL_OUTPUT_NAME\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_storage_service, session_scope\n\n\ndef resolve_mcp_config(\n server_name: str, # noqa: ARG001\n server_config_from_value: dict | None,\n server_config_from_db: dict | None,\n) -> dict | None:\n \"\"\"Resolve MCP server config with proper precedence.\n\n Resolves the configuration for an MCP server with the following precedence:\n 1. Database config (takes priority) - ensures edits are reflected\n 2. Config from value/tweaks (fallback) - allows REST API to provide config for new servers\n\n Args:\n server_name: Name of the MCP server\n server_config_from_value: Config provided via value/tweaks (optional)\n server_config_from_db: Config from database (optional)\n\n Returns:\n Final config to use (DB takes priority, falls back to value)\n Returns None if no config found in either location\n \"\"\"\n if server_config_from_db:\n return server_config_from_db\n return server_config_from_value\n\n\nclass MCPToolsComponent(ComponentWithCache):\n \"\"\"MCP Tools component.\n\n Behaviour notes:\n - Stale agent tools vs server were caused by Langflow caching the Toolset output\n (``Output.cache=True`` plus the persisted ``output.value`` in saved flow JSON), not\n only by the user-facing \"Use Cached Server\" (``use_cache``) toggle. ``_build_tool_output``\n declares the Toolset output with ``cache=False`` and ``map_outputs`` overrides the\n persisted value for existing flows so every run resolves a fresh tool list.\n - ``update_tool_list`` is serialized with an ``asyncio.Lock`` so concurrent calls do not\n share the same Streamable HTTP client session; overlapping POST/DELETE cycles otherwise\n surface as HTTP 404 and the MCP SDK reports ``Session terminated``.\n - All diagnostic logs are at DEBUG level to avoid swamping logs on hot paths (an agent\n may call ``_get_tools`` per step). Header values are never logged; only header *keys*.\n \"\"\"\n\n # Short-lived in-memory cache window (seconds) for ``_get_tools``. When the\n # same header-hash key is requested again within this window the cached tool\n # list is reused so parallel agent runs that share identical auth don't\n # each pay for a fresh MCP round-trip. Set to ``0`` to disable. This is\n # distinct from the \"Use Cached Server\" (``use_cache``) toggle which only\n # controls the shared cross-request server cache.\n TOOL_TTL_SECS: int = 30\n # Upper bound on the per-instance TTL cache. Oldest entries are evicted\n # when the cap is reached. Keeps memory bounded for flows where the same\n # component handles many rotating auth contexts (e.g. per-tenant tokens).\n TOOL_TTL_MAX_ENTRIES: int = 32\n # Upper bound on the shared cross-request ``servers`` cache. Each\n # (server_name, header-hash) pair is a distinct entry; without a bound a\n # tenant that rotates session tokens would grow this map without limit.\n SHARED_SERVERS_CACHE_MAX_ENTRIES: int = 64\n\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(\n component_cache=self._shared_component_cache\n )\n # One MCP stdio/streamable client pair per component; concurrent update_tool_list calls\n # otherwise race (session DELETE vs POST) and the MCP SDK surfaces HTTP 404 as \"Session terminated\".\n self._update_tool_list_lock = asyncio.Lock()\n # Per-instance TTL cache for ``_get_tools``: {cache_key: (monotonic_ts, tools)}.\n # Declared here (not at class scope) so every component gets its own dict —\n # a class-level dict would be shared across every MCPToolsComponent in the process,\n # leaking tool lists across tenants that happen to hash to the same key.\n self._ttl_tool_cache: dict[str, tuple[float, list]] = {}\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n def _normalized_headers_for_cache(self) -> dict[str, str]:\n \"\"\"Component headers as a dict for stable cache keying (auth / tweaks).\"\"\"\n component_headers = getattr(self, \"headers\", None) or []\n if isinstance(component_headers, list):\n return {\n str(item[\"key\"]): str(item[\"value\"])\n for item in component_headers\n if isinstance(item, dict) and \"key\" in item and \"value\" in item\n }\n if isinstance(component_headers, dict):\n return {str(k): str(v) for k, v in component_headers.items()}\n return {}\n\n def _mcp_servers_cache_key(self, server_name: str) -> str:\n \"\"\"Cache key for shared servers map; includes headers so auth/tweak changes get distinct entries.\"\"\"\n if not server_name:\n return \"\"\n hdrs = self._normalized_headers_for_cache()\n if not hdrs:\n return server_name\n payload = json.dumps(hdrs, sort_keys=True)\n digest = hashlib.sha256(payload.encode()).hexdigest()[:16]\n return f\"{server_name}:{digest}\"\n\n def _build_tool_output(self) -> Output:\n # Do not cache Toolset output. This is separate from the MCP \"Use Cached Server\" (use_cache)\n # toggle: Langflow's Output.cache defaults to True and was memoizing the first to_toolkit()\n # result, so per-request tweaks/headers never refreshed bound tools even when use_cache=False.\n return Output(\n name=TOOL_OUTPUT_NAME,\n display_name=TOOL_OUTPUT_DISPLAY_NAME,\n method=\"to_toolkit\",\n types=[\"Tool\"],\n cache=False,\n )\n\n def map_outputs(self) -> None:\n \"\"\"Override the persisted ``component_as_tool`` cache flag from saved flow JSON.\n\n ``_build_tool_output`` already returns the output with ``cache=False``, but the\n flow JSON for existing flows often stores ``cache: true`` for ``component_as_tool``\n and that persisted value wins over the declaration. Forcing ``cache=False`` here\n guarantees saved flows also bypass Output memoization and get a fresh tool list\n on every run.\n \"\"\"\n super().map_outputs()\n if TOOL_OUTPUT_NAME in self._outputs_map:\n self._outputs_map[TOOL_OUTPUT_NAME].cache = False\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n \"verify_ssl\",\n \"headers\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-tools\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n BoolInput(\n name=\"verify_ssl\",\n display_name=\"Verify SSL Certificate\",\n info=(\n \"Enable SSL certificate verification for HTTPS connections. \"\n \"Disable only for development/testing with self-signed certificates.\"\n ),\n value=True,\n advanced=True,\n ),\n DictInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=(\n \"HTTP headers to include with MCP server requests. \"\n \"Useful for authentication (e.g., Authorization header). \"\n \"These headers override any headers configured in the MCP server settings.\"\n ),\n advanced=True,\n is_list=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n refresh_button=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n input_schema = tool_obj.args_schema\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n await logger.adebug(\"MCP update_tool_list: empty server_name, clearing tools\")\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n servers_cache_key = self._mcp_servers_cache_key(server_name)\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n header_keys = sorted(self._normalized_headers_for_cache().keys())\n await logger.adebug(\n \"MCP update_tool_list: start server=%r use_cache=%s shared_cache_key=%r header_keys=%s\",\n server_name,\n use_cache,\n servers_cache_key,\n header_keys,\n )\n\n async with self._update_tool_list_lock:\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(servers_cache_key) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n tools_from_cache = cached[\"tools\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and servers_cache_key in current_servers_cache:\n current_servers_cache.pop(servers_cache_key)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n self.tools = tools_from_cache\n self.tool_names = [t.name for t in self.tools if hasattr(t, \"name\")]\n self._tool_cache = cached[\"tool_cache\"]\n await logger.adebug(\n \"MCP update_tool_list: shared_servers_cache HIT count=%d server=%r\",\n len(self.tools),\n server_name,\n )\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n # Try to fetch from database first to ensure we have the latest config\n # This ensures database updates (like editing a server) take effect\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n\n from lfx.services.deps import get_settings_service\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n\n server_config_from_db = None\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config_from_db = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # Resolve config with proper precedence: DB takes priority, falls back to value\n server_config = resolve_mcp_config(\n server_name=server_name,\n server_config_from_value=server_config_from_value,\n server_config_from_db=server_config_from_db,\n )\n\n if not server_config:\n self.tools = []\n await logger.awarning(\n \"MCP update_tool_list: no server_config after resolve server=%r\",\n server_name,\n )\n return [], {\"name\": server_name, \"config\": server_config}\n\n # Add verify_ssl option to server config if not present\n if \"verify_ssl\" not in server_config:\n verify_ssl = getattr(self, \"verify_ssl\", True)\n server_config[\"verify_ssl\"] = verify_ssl\n\n # Merge headers from component input with server config headers\n # Component headers take precedence over server config headers\n component_headers = getattr(self, \"headers\", None) or []\n if component_headers:\n # Convert list of {\"key\": k, \"value\": v} to dict\n component_headers_dict = {}\n if isinstance(component_headers, list):\n for item in component_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n component_headers_dict[item[\"key\"]] = item[\"value\"]\n elif isinstance(component_headers, dict):\n component_headers_dict = component_headers\n\n if component_headers_dict:\n existing_headers = server_config.get(\"headers\", {}) or {}\n # Ensure existing_headers is a dict (convert from list if needed)\n if isinstance(existing_headers, list):\n existing_dict = {}\n for item in existing_headers:\n if isinstance(item, dict) and \"key\" in item and \"value\" in item:\n existing_dict[item[\"key\"]] = item[\"value\"]\n existing_headers = existing_dict\n merged_headers = {**existing_headers, **component_headers_dict}\n server_config[\"headers\"] = merged_headers\n # Get request_variables from graph context for global variable resolution\n request_variables = None\n if hasattr(self, \"graph\") and self.graph and hasattr(self.graph, \"context\"):\n request_variables = self.graph.context.get(\"request_variables\")\n\n # Only load global variables from database if we have headers that might use them\n # This avoids unnecessary database queries when headers are empty\n has_headers = server_config.get(\"headers\") and len(server_config.get(\"headers\", {})) > 0\n if not request_variables and has_headers:\n try:\n from lfx.services.deps import get_variable_service\n\n variable_service = get_variable_service()\n if variable_service:\n async with session_scope() as db:\n request_variables = await variable_service.get_all_decrypted_variables(\n user_id=self.user_id, session=db\n )\n except Exception as e: # noqa: BLE001\n await logger.awarning(f\"Failed to load global variables for MCP component: {e}\")\n\n await logger.adebug(\n \"MCP update_tool_list: calling update_tools server=%r mode_headers=%s\",\n server_name,\n sorted((server_config.get(\"headers\") or {}).keys())\n if isinstance(server_config.get(\"headers\"), dict)\n else \"list-or-empty\",\n )\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_streamable_http_client=self.streamable_http_client,\n request_variables=request_variables,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n await logger.adebug(\n \"MCP update_tool_list: fetched from MCP count=%d server=%r\",\n len(tool_list),\n server_name,\n )\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache with bounded size (FIFO eviction).\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n # Because the cache key now includes a header hash, a tenant that\n # rotates session tokens would grow this map without bound. Drop\n # the oldest entry when over the limit.\n max_entries = self.SHARED_SERVERS_CACHE_MAX_ENTRIES\n while (\n len(current_servers_cache) >= max_entries and servers_cache_key not in current_servers_cache\n ):\n oldest_key = next(iter(current_servers_cache))\n current_servers_cache.pop(oldest_key, None)\n current_servers_cache[servers_cache_key] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n await logger.adebug(\n \"MCP update_tool_list: wrote shared_servers_cache key=%r size=%d\",\n servers_cache_key,\n len(current_servers_cache),\n )\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = (\n f\"Timeout updating tool list: {e!s}. \"\n \"Raise ``LANGFLOW_MCP_SERVER_TIMEOUT`` for the deployment if the MCP \"\n \"server legitimately needs more time to respond.\"\n )\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n # Always refresh tools when cache is disabled, or when tools list is empty\n # This ensures database edits are reflected immediately when cache is disabled\n use_cache = getattr(self, \"use_cache\", False)\n if len(self.tools) == 0 or not use_cache:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n servers_cache_key_ui = self._mcp_servers_cache_key(current_server_name) if current_server_name else \"\"\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n # Only treat as a server change if there was a previous server selection.\n # Cold cache (_last_selected_server=\"\") on initial flow load is NOT a server change —\n # the user didn't switch anything, the backend just hasn't seen this component yet.\n server_changed = bool(_last_selected_server and current_server_name != _last_selected_server)\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n\n # Get use_cache setting to determine if we should use cached data\n use_cache = getattr(self, \"use_cache\", False)\n\n # Fast path: if server didn't change and we already have options, keep them as-is\n # BUT only if caching is enabled, we're in tool mode, or it's the initial load\n existing_options = build_config.get(\"tool\", {}).get(\"options\") or []\n if not server_changed and existing_options:\n # In non-tool mode with cache disabled, skip the fast path to force refresh\n # BUT on initial load (cold cache), always preserve saved options from the flow\n if not is_in_tool_mode and not use_cache and _last_selected_server:\n pass # Continue to refresh logic below (user-initiated with cache disabled)\n else:\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n return build_config\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n # OR if caching is disabled (to force refresh in non-tool mode)\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"] and use_cache:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(servers_cache_key_ui)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # When cache is disabled, clear any cached data for this server\n # This ensures we always fetch fresh data from the database\n if not use_cache and current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict) and servers_cache_key_ui in servers_cache:\n servers_cache.pop(servers_cache_key_ui)\n safe_cache_set(self._shared_component_cache, \"servers\", servers_cache)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name and use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Clear tools when cache is disabled OR when we don't have cached tools\n # This ensures fresh tools are fetched after database edits\n if not cached_tools or not use_cache:\n self.tools = [] # Clear previous tools to force refresh\n\n # Clear previous tool inputs if:\n # 1. Server actually changed\n # 2. Cache is disabled (meaning tool list will be refreshed)\n if server_changed or not use_cache:\n self.remove_non_default_keys(build_config)\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Actually fetch tools now instead of deferring to a frontend callback.\n # The frontend has no reliable mechanism to trigger a second\n # update_build_config call for the \"tool\" field after this response,\n # so we must populate the options here.\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list(\n mcp_server_value=field_value\n )\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools for MCP server: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n # Force a value refresh only when the user genuinely switched servers.\n # server_changed is only True for real user-initiated changes (not initial load).\n if server_changed:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"show\"] = True\n # Fetch tools immediately instead of showing \"Loading tools...\"\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n except (ValueError, ImportError, ConnectionError, OSError, RuntimeError) as e:\n msg = f\"Error loading tools when toggling tool mode: {e!s}\"\n await logger.awarning(msg)\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n @staticmethod\n def _unwrap_optional_annotation(annotation: Any) -> Any:\n \"\"\"Remove a single None branch from a union annotation.\"\"\"\n if isinstance(annotation, UnionType):\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1:\n return non_none[0]\n return annotation\n\n if get_origin(annotation) is None:\n return annotation\n\n non_none = [item for item in get_args(annotation) if item is not type(None)]\n if len(non_none) == 1 and len(non_none) != len(get_args(annotation)):\n return non_none[0]\n return annotation\n\n @classmethod\n def _is_object_like_annotation(cls, annotation: Any) -> bool:\n \"\"\"Return True when the annotation represents a dict-like payload.\"\"\"\n annotation = cls._unwrap_optional_annotation(annotation)\n origin = get_origin(annotation)\n if origin is dict:\n return True\n return annotation is dict or (isinstance(annotation, type) and issubclass(annotation, BaseModel))\n\n @classmethod\n def _should_include_tool_argument(cls, model_field: Any, value: Any) -> bool:\n \"\"\"Omit blank optional values so MCP server defaults remain intact.\"\"\"\n if value is None:\n return False\n\n if model_field.is_required():\n return True\n\n if isinstance(value, str) and value == \"\":\n return False\n\n return not (\n value == {} and model_field.default is None and cls._is_object_like_annotation(model_field.annotation)\n )\n\n def _build_tool_kwargs(self, args_schema: type[BaseModel]) -> dict[str, Any]:\n \"\"\"Collect tool kwargs from component inputs, omitting blank optional values.\"\"\"\n kwargs: dict[str, Any] = {}\n for arg_name, model_field in args_schema.model_fields.items():\n value = getattr(self, arg_name, None)\n if isinstance(value, Message):\n value = value.text\n\n if self._should_include_tool_argument(model_field, value):\n kwargs[arg_name] = value\n\n return kwargs\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n langflow_inputs = schema_to_langflow_inputs(tool.args_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs (only for the current tool)\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Remove ALL non-default keys (all previous tool inputs)\n self.remove_non_default_keys(build_config)\n\n # Get and validate new inputs for the selected tool\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config for the selected tool only\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.streamable_http_client.set_session_context(session_context)\n exec_tool = self._tool_cache[self.tool]\n kwargs = self._build_tool_kwargs(exec_tool.args_schema)\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n item_dict = self.process_output_item(item_dict)\n tool_content.append(item_dict)\n\n if isinstance(tool_content, list) and all(isinstance(x, dict) for x in tool_content):\n return DataFrame(tool_content)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def process_output_item(self, item_dict):\n \"\"\"Process the output of a tool.\"\"\"\n if item_dict.get(\"type\") == \"text\":\n text = item_dict.get(\"text\")\n try:\n parsed = json.loads(text)\n # Ensure we always return a dictionary for DataFrame compatibility\n if isinstance(parsed, dict):\n return parsed\n # Wrap non-dict parsed values in a dictionary\n return {\"text\": text, \"parsed_value\": parsed, \"type\": \"text\"} # noqa: TRY300\n except json.JSONDecodeError:\n return item_dict\n return item_dict\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Load tools for the agent toolkit; always refresh from ``update_tool_list``.\n\n ``_not_load_actions`` only applies to UI build_config flows (tool dropdown). Agent\n runs must always bind the current tool list (including after header/tweak changes).\n\n A short-lived TTL cache (``TOOL_TTL_SECS``, header-hash-keyed) skips the MCP\n round-trip when the same auth context is re-queried quickly (e.g. parallel agent\n steps sharing the same tweaked headers). The cache is per component instance and\n bounded by ``TOOL_TTL_MAX_ENTRIES``; it is distinct from the \"Use Cached Server\"\n (``use_cache``) toggle which controls the shared cross-request cache.\n \"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n srv = mcp_server.get(\"name\") if isinstance(mcp_server, dict) else mcp_server\n\n ttl = self.TOOL_TTL_SECS\n ttl_key = self._mcp_servers_cache_key(srv or \"\") if ttl > 0 and srv else \"\"\n\n # TTL cache lookup + expired-entry eviction.\n if ttl > 0 and ttl_key:\n cached = self._ttl_tool_cache.get(ttl_key)\n if cached is not None:\n ts, cached_tools = cached\n age = time.monotonic() - ts\n if age < ttl:\n await logger.adebug(\n \"MCP _get_tools: TTL cache hit count=%d age=%.1fs server=%r\",\n len(cached_tools),\n age,\n srv,\n )\n return cached_tools\n # Stale — drop it so the size check below stays tight.\n self._ttl_tool_cache.pop(ttl_key, None)\n\n await logger.adebug(\"MCP _get_tools: fetching tool list server=%r\", srv)\n tools, _ = await self.update_tool_list(mcp_server)\n await logger.adebug(\"MCP _get_tools: fetched %d tools server=%r\", len(tools), srv)\n\n # TTL cache store with bounded size (FIFO eviction of the oldest entry).\n if ttl > 0 and ttl_key and tools:\n if len(self._ttl_tool_cache) >= self.TOOL_TTL_MAX_ENTRIES:\n # dict preserves insertion order — pop the oldest entry.\n oldest_key = next(iter(self._ttl_tool_cache))\n self._ttl_tool_cache.pop(oldest_key, None)\n self._ttl_tool_cache[ttl_key] = (time.monotonic(), tools)\n\n return tools\n" }, "headers": { "_input_type": "DictInput", @@ -118100,6 +118100,6 @@ "num_components": 355, "num_modules": 97 }, - "sha256": "889c7d992ef1de5ddce6bbfe6b04391f633978a90dee76aba2656dc7fcba3e71", + "sha256": "5078ad208a7f1d5ff6cc941c9e50652cf26b5705be21c10d59bf39d6c2594261", "version": "0.5.0" } diff --git a/src/lfx/src/lfx/components/models_and_agents/mcp_component.py b/src/lfx/src/lfx/components/models_and_agents/mcp_component.py index f39040d3dd..5aff257852 100644 --- a/src/lfx/src/lfx/components/models_and_agents/mcp_component.py +++ b/src/lfx/src/lfx/components/models_and_agents/mcp_component.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import hashlib import json +import time import uuid from types import UnionType from typing import Any, get_args, get_origin @@ -15,6 +17,7 @@ from lfx.base.mcp.util import ( MCPStreamableHttpClient, update_tools, ) +from lfx.base.tools.constants import TOOL_OUTPUT_DISPLAY_NAME, TOOL_OUTPUT_NAME from lfx.custom.custom_component.component_with_cache import ComponentWithCache from lfx.inputs.inputs import InputTypes # noqa: TC001 from lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output @@ -51,6 +54,37 @@ def resolve_mcp_config( class MCPToolsComponent(ComponentWithCache): + """MCP Tools component. + + Behaviour notes: + - Stale agent tools vs server were caused by Langflow caching the Toolset output + (``Output.cache=True`` plus the persisted ``output.value`` in saved flow JSON), not + only by the user-facing "Use Cached Server" (``use_cache``) toggle. ``_build_tool_output`` + declares the Toolset output with ``cache=False`` and ``map_outputs`` overrides the + persisted value for existing flows so every run resolves a fresh tool list. + - ``update_tool_list`` is serialized with an ``asyncio.Lock`` so concurrent calls do not + share the same Streamable HTTP client session; overlapping POST/DELETE cycles otherwise + surface as HTTP 404 and the MCP SDK reports ``Session terminated``. + - All diagnostic logs are at DEBUG level to avoid swamping logs on hot paths (an agent + may call ``_get_tools`` per step). Header values are never logged; only header *keys*. + """ + + # Short-lived in-memory cache window (seconds) for ``_get_tools``. When the + # same header-hash key is requested again within this window the cached tool + # list is reused so parallel agent runs that share identical auth don't + # each pay for a fresh MCP round-trip. Set to ``0`` to disable. This is + # distinct from the "Use Cached Server" (``use_cache``) toggle which only + # controls the shared cross-request server cache. + TOOL_TTL_SECS: int = 30 + # Upper bound on the per-instance TTL cache. Oldest entries are evicted + # when the cap is reached. Keeps memory bounded for flows where the same + # component handles many rotating auth contexts (e.g. per-tenant tokens). + TOOL_TTL_MAX_ENTRIES: int = 32 + # Upper bound on the shared cross-request ``servers`` cache. Each + # (server_name, header-hash) pair is a distinct entry; without a bound a + # tenant that rotates session tokens would grow this map without limit. + SHARED_SERVERS_CACHE_MAX_ENTRIES: int = 64 + schema_inputs: list = [] tools: list[StructuredTool] = [] _not_load_actions: bool = False @@ -67,6 +101,14 @@ class MCPToolsComponent(ComponentWithCache): self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient( component_cache=self._shared_component_cache ) + # One MCP stdio/streamable client pair per component; concurrent update_tool_list calls + # otherwise race (session DELETE vs POST) and the MCP SDK surfaces HTTP 404 as "Session terminated". + self._update_tool_list_lock = asyncio.Lock() + # Per-instance TTL cache for ``_get_tools``: {cache_key: (monotonic_ts, tools)}. + # Declared here (not at class scope) so every component gets its own dict — + # a class-level dict would be shared across every MCPToolsComponent in the process, + # leaking tool lists across tenants that happen to hash to the same key. + self._ttl_tool_cache: dict[str, tuple[float, list]] = {} def _ensure_cache_structure(self): """Ensure the cache has the required structure.""" @@ -80,6 +122,55 @@ class MCPToolsComponent(ComponentWithCache): if last_server_value is None: safe_cache_set(self._shared_component_cache, "last_selected_server", "") + def _normalized_headers_for_cache(self) -> dict[str, str]: + """Component headers as a dict for stable cache keying (auth / tweaks).""" + component_headers = getattr(self, "headers", None) or [] + if isinstance(component_headers, list): + return { + str(item["key"]): str(item["value"]) + for item in component_headers + if isinstance(item, dict) and "key" in item and "value" in item + } + if isinstance(component_headers, dict): + return {str(k): str(v) for k, v in component_headers.items()} + return {} + + def _mcp_servers_cache_key(self, server_name: str) -> str: + """Cache key for shared servers map; includes headers so auth/tweak changes get distinct entries.""" + if not server_name: + return "" + hdrs = self._normalized_headers_for_cache() + if not hdrs: + return server_name + payload = json.dumps(hdrs, sort_keys=True) + digest = hashlib.sha256(payload.encode()).hexdigest()[:16] + return f"{server_name}:{digest}" + + def _build_tool_output(self) -> Output: + # Do not cache Toolset output. This is separate from the MCP "Use Cached Server" (use_cache) + # toggle: Langflow's Output.cache defaults to True and was memoizing the first to_toolkit() + # result, so per-request tweaks/headers never refreshed bound tools even when use_cache=False. + return Output( + name=TOOL_OUTPUT_NAME, + display_name=TOOL_OUTPUT_DISPLAY_NAME, + method="to_toolkit", + types=["Tool"], + cache=False, + ) + + def map_outputs(self) -> None: + """Override the persisted ``component_as_tool`` cache flag from saved flow JSON. + + ``_build_tool_output`` already returns the output with ``cache=False``, but the + flow JSON for existing flows often stores ``cache: true`` for ``component_as_tool`` + and that persisted value wins over the declaration. Forcing ``cache=False`` here + guarantees saved flows also bypass Output memoization and get a fresh tool list + on every run. + """ + super().map_outputs() + if TOOL_OUTPUT_NAME in self._outputs_map: + self._outputs_map[TOOL_OUTPUT_NAME].cache = False + default_keys: list[str] = [ "code", "_type", @@ -198,164 +289,218 @@ class MCPToolsComponent(ComponentWithCache): server_name = mcp_server if not server_name: self.tools = [] + await logger.adebug("MCP update_tool_list: empty server_name, clearing tools") return [], {"name": server_name, "config": server_config_from_value} + servers_cache_key = self._mcp_servers_cache_key(server_name) + # Check if caching is enabled, default to False use_cache = getattr(self, "use_cache", False) + header_keys = sorted(self._normalized_headers_for_cache().keys()) + await logger.adebug( + "MCP update_tool_list: start server=%r use_cache=%s shared_cache_key=%r header_keys=%s", + server_name, + use_cache, + servers_cache_key, + header_keys, + ) - # Use shared cache if available and caching is enabled - cached = None - if use_cache: - servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) - cached = servers_cache.get(server_name) if isinstance(servers_cache, dict) else None - - if cached is not None: - try: - self.tools = cached["tools"] - self.tool_names = cached["tool_names"] - self._tool_cache = cached["tool_cache"] - server_config_from_value = cached["config"] - except (TypeError, KeyError, AttributeError) as e: - # Handle corrupted cache data by clearing it and continuing to fetch fresh tools - msg = f"Unable to use cached data for MCP Server{server_name}: {e}" - await logger.awarning(msg) - # Clear the corrupted cache entry - current_servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) - if isinstance(current_servers_cache, dict) and server_name in current_servers_cache: - current_servers_cache.pop(server_name) - safe_cache_set(self._shared_component_cache, "servers", current_servers_cache) - else: - return self.tools, {"name": server_name, "config": server_config_from_value} - - try: - # Try to fetch from database first to ensure we have the latest config - # This ensures database updates (like editing a server) take effect - try: - from langflow.api.v2.mcp import get_server - from langflow.services.database.models.user.crud import get_user_by_id - - from lfx.services.deps import get_settings_service - except ImportError as e: - msg = ( - "Langflow MCP server functionality is not available. " - "This feature requires the full Langflow installation." - ) - raise ImportError(msg) from e - - server_config_from_db = None - async with session_scope() as db: - if not self.user_id: - msg = "User ID is required for fetching MCP tools." - raise ValueError(msg) - current_user = await get_user_by_id(db, self.user_id) - - # Try to get server config from DB/API - server_config_from_db = await get_server( - server_name, - current_user, - db, - storage_service=get_storage_service(), - settings_service=get_settings_service(), - ) - - # Resolve config with proper precedence: DB takes priority, falls back to value - server_config = resolve_mcp_config( - server_name=server_name, - server_config_from_value=server_config_from_value, - server_config_from_db=server_config_from_db, - ) - - if not server_config: - self.tools = [] - return [], {"name": server_name, "config": server_config} - - # Add verify_ssl option to server config if not present - if "verify_ssl" not in server_config: - verify_ssl = getattr(self, "verify_ssl", True) - server_config["verify_ssl"] = verify_ssl - - # Merge headers from component input with server config headers - # Component headers take precedence over server config headers - component_headers = getattr(self, "headers", None) or [] - if component_headers: - # Convert list of {"key": k, "value": v} to dict - component_headers_dict = {} - if isinstance(component_headers, list): - for item in component_headers: - if isinstance(item, dict) and "key" in item and "value" in item: - component_headers_dict[item["key"]] = item["value"] - elif isinstance(component_headers, dict): - component_headers_dict = component_headers - - if component_headers_dict: - existing_headers = server_config.get("headers", {}) or {} - # Ensure existing_headers is a dict (convert from list if needed) - if isinstance(existing_headers, list): - existing_dict = {} - for item in existing_headers: - if isinstance(item, dict) and "key" in item and "value" in item: - existing_dict[item["key"]] = item["value"] - existing_headers = existing_dict - merged_headers = {**existing_headers, **component_headers_dict} - server_config["headers"] = merged_headers - # Get request_variables from graph context for global variable resolution - request_variables = None - if hasattr(self, "graph") and self.graph and hasattr(self.graph, "context"): - request_variables = self.graph.context.get("request_variables") - - # Only load global variables from database if we have headers that might use them - # This avoids unnecessary database queries when headers are empty - has_headers = server_config.get("headers") and len(server_config.get("headers", {})) > 0 - if not request_variables and has_headers: - try: - from lfx.services.deps import get_variable_service - - variable_service = get_variable_service() - if variable_service: - async with session_scope() as db: - request_variables = await variable_service.get_all_decrypted_variables( - user_id=self.user_id, session=db - ) - except Exception as e: # noqa: BLE001 - await logger.awarning(f"Failed to load global variables for MCP component: {e}") - - _, tool_list, tool_cache = await update_tools( - server_name=server_name, - server_config=server_config, - mcp_stdio_client=self.stdio_client, - mcp_streamable_http_client=self.streamable_http_client, - request_variables=request_variables, - ) - - self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")] - self._tool_cache = tool_cache - self.tools = tool_list - - # Cache the result only if caching is enabled + async with self._update_tool_list_lock: + # Use shared cache if available and caching is enabled + cached = None if use_cache: - cache_data = { - "tools": tool_list, - "tool_names": self.tool_names, - "tool_cache": tool_cache, - "config": server_config, - } + servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) + cached = servers_cache.get(servers_cache_key) if isinstance(servers_cache, dict) else None - # Safely update the servers cache - current_servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) - if isinstance(current_servers_cache, dict): - current_servers_cache[server_name] = cache_data - safe_cache_set(self._shared_component_cache, "servers", current_servers_cache) + if cached is not None: + try: + tools_from_cache = cached["tools"] + server_config_from_value = cached["config"] + except (TypeError, KeyError, AttributeError) as e: + # Handle corrupted cache data by clearing it and continuing to fetch fresh tools + msg = f"Unable to use cached data for MCP Server{server_name}: {e}" + await logger.awarning(msg) + # Clear the corrupted cache entry + current_servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) + if isinstance(current_servers_cache, dict) and servers_cache_key in current_servers_cache: + current_servers_cache.pop(servers_cache_key) + safe_cache_set(self._shared_component_cache, "servers", current_servers_cache) + else: + self.tools = tools_from_cache + self.tool_names = [t.name for t in self.tools if hasattr(t, "name")] + self._tool_cache = cached["tool_cache"] + await logger.adebug( + "MCP update_tool_list: shared_servers_cache HIT count=%d server=%r", + len(self.tools), + server_name, + ) + return self.tools, {"name": server_name, "config": server_config_from_value} - except (TimeoutError, asyncio.TimeoutError) as e: - msg = f"Timeout updating tool list: {e!s}" - await logger.aexception(msg) - raise TimeoutError(msg) from e - except Exception as e: - msg = f"Error updating tool list: {e!s}" - await logger.aexception(msg) - raise ValueError(msg) from e - else: - return tool_list, {"name": server_name, "config": server_config} + try: + # Try to fetch from database first to ensure we have the latest config + # This ensures database updates (like editing a server) take effect + try: + from langflow.api.v2.mcp import get_server + from langflow.services.database.models.user.crud import get_user_by_id + + from lfx.services.deps import get_settings_service + except ImportError as e: + msg = ( + "Langflow MCP server functionality is not available. " + "This feature requires the full Langflow installation." + ) + raise ImportError(msg) from e + + server_config_from_db = None + async with session_scope() as db: + if not self.user_id: + msg = "User ID is required for fetching MCP tools." + raise ValueError(msg) + current_user = await get_user_by_id(db, self.user_id) + + # Try to get server config from DB/API + server_config_from_db = await get_server( + server_name, + current_user, + db, + storage_service=get_storage_service(), + settings_service=get_settings_service(), + ) + + # Resolve config with proper precedence: DB takes priority, falls back to value + server_config = resolve_mcp_config( + server_name=server_name, + server_config_from_value=server_config_from_value, + server_config_from_db=server_config_from_db, + ) + + if not server_config: + self.tools = [] + await logger.awarning( + "MCP update_tool_list: no server_config after resolve server=%r", + server_name, + ) + return [], {"name": server_name, "config": server_config} + + # Add verify_ssl option to server config if not present + if "verify_ssl" not in server_config: + verify_ssl = getattr(self, "verify_ssl", True) + server_config["verify_ssl"] = verify_ssl + + # Merge headers from component input with server config headers + # Component headers take precedence over server config headers + component_headers = getattr(self, "headers", None) or [] + if component_headers: + # Convert list of {"key": k, "value": v} to dict + component_headers_dict = {} + if isinstance(component_headers, list): + for item in component_headers: + if isinstance(item, dict) and "key" in item and "value" in item: + component_headers_dict[item["key"]] = item["value"] + elif isinstance(component_headers, dict): + component_headers_dict = component_headers + + if component_headers_dict: + existing_headers = server_config.get("headers", {}) or {} + # Ensure existing_headers is a dict (convert from list if needed) + if isinstance(existing_headers, list): + existing_dict = {} + for item in existing_headers: + if isinstance(item, dict) and "key" in item and "value" in item: + existing_dict[item["key"]] = item["value"] + existing_headers = existing_dict + merged_headers = {**existing_headers, **component_headers_dict} + server_config["headers"] = merged_headers + # Get request_variables from graph context for global variable resolution + request_variables = None + if hasattr(self, "graph") and self.graph and hasattr(self.graph, "context"): + request_variables = self.graph.context.get("request_variables") + + # Only load global variables from database if we have headers that might use them + # This avoids unnecessary database queries when headers are empty + has_headers = server_config.get("headers") and len(server_config.get("headers", {})) > 0 + if not request_variables and has_headers: + try: + from lfx.services.deps import get_variable_service + + variable_service = get_variable_service() + if variable_service: + async with session_scope() as db: + request_variables = await variable_service.get_all_decrypted_variables( + user_id=self.user_id, session=db + ) + except Exception as e: # noqa: BLE001 + await logger.awarning(f"Failed to load global variables for MCP component: {e}") + + await logger.adebug( + "MCP update_tool_list: calling update_tools server=%r mode_headers=%s", + server_name, + sorted((server_config.get("headers") or {}).keys()) + if isinstance(server_config.get("headers"), dict) + else "list-or-empty", + ) + + _, tool_list, tool_cache = await update_tools( + server_name=server_name, + server_config=server_config, + mcp_stdio_client=self.stdio_client, + mcp_streamable_http_client=self.streamable_http_client, + request_variables=request_variables, + ) + + self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")] + self._tool_cache = tool_cache + self.tools = tool_list + + await logger.adebug( + "MCP update_tool_list: fetched from MCP count=%d server=%r", + len(tool_list), + server_name, + ) + + # Cache the result only if caching is enabled + if use_cache: + cache_data = { + "tools": tool_list, + "tool_names": self.tool_names, + "tool_cache": tool_cache, + "config": server_config, + } + + # Safely update the servers cache with bounded size (FIFO eviction). + current_servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) + if isinstance(current_servers_cache, dict): + # Because the cache key now includes a header hash, a tenant that + # rotates session tokens would grow this map without bound. Drop + # the oldest entry when over the limit. + max_entries = self.SHARED_SERVERS_CACHE_MAX_ENTRIES + while ( + len(current_servers_cache) >= max_entries and servers_cache_key not in current_servers_cache + ): + oldest_key = next(iter(current_servers_cache)) + current_servers_cache.pop(oldest_key, None) + current_servers_cache[servers_cache_key] = cache_data + safe_cache_set(self._shared_component_cache, "servers", current_servers_cache) + await logger.adebug( + "MCP update_tool_list: wrote shared_servers_cache key=%r size=%d", + servers_cache_key, + len(current_servers_cache), + ) + + except (TimeoutError, asyncio.TimeoutError) as e: + msg = ( + f"Timeout updating tool list: {e!s}. " + "Raise ``LANGFLOW_MCP_SERVER_TIMEOUT`` for the deployment if the MCP " + "server legitimately needs more time to respond." + ) + await logger.aexception(msg) + raise TimeoutError(msg) from e + except Exception as e: + msg = f"Error updating tool list: {e!s}" + await logger.aexception(msg) + raise ValueError(msg) from e + else: + return tool_list, {"name": server_name, "config": server_config} async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict: """Toggle the visibility of connection-specific fields based on the selected mode.""" @@ -420,6 +565,7 @@ class MCPToolsComponent(ComponentWithCache): build_config["tool_placeholder"]["tool_mode"] = True current_server_name = field_value.get("name") if isinstance(field_value, dict) else field_value + servers_cache_key_ui = self._mcp_servers_cache_key(current_server_name) if current_server_name else "" _last_selected_server = safe_cache_get(self._shared_component_cache, "last_selected_server", "") # Only treat as a server change if there was a previous server selection. # Cold cache (_last_selected_server="") on initial flow load is NOT a server change — @@ -452,7 +598,7 @@ class MCPToolsComponent(ComponentWithCache): if current_server_name: servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) if isinstance(servers_cache, dict): - cached = servers_cache.get(current_server_name) + cached = servers_cache.get(servers_cache_key_ui) if cached is not None and cached.get("tool_names"): cached_tools = cached["tool_names"] current_tools = build_config["tool"]["options"] @@ -466,8 +612,8 @@ class MCPToolsComponent(ComponentWithCache): # This ensures we always fetch fresh data from the database if not use_cache and current_server_name: servers_cache = safe_cache_get(self._shared_component_cache, "servers", {}) - if isinstance(servers_cache, dict) and current_server_name in servers_cache: - servers_cache.pop(current_server_name) + if isinstance(servers_cache, dict) and servers_cache_key_ui in servers_cache: + servers_cache.pop(servers_cache_key_ui) safe_cache_set(self._shared_component_cache, "servers", servers_cache) # Check if tools are already cached for this server before clearing @@ -774,9 +920,50 @@ class MCPToolsComponent(ComponentWithCache): return None async def _get_tools(self): - """Get cached tools or update if necessary.""" + """Load tools for the agent toolkit; always refresh from ``update_tool_list``. + + ``_not_load_actions`` only applies to UI build_config flows (tool dropdown). Agent + runs must always bind the current tool list (including after header/tweak changes). + + A short-lived TTL cache (``TOOL_TTL_SECS``, header-hash-keyed) skips the MCP + round-trip when the same auth context is re-queried quickly (e.g. parallel agent + steps sharing the same tweaked headers). The cache is per component instance and + bounded by ``TOOL_TTL_MAX_ENTRIES``; it is distinct from the "Use Cached Server" + (``use_cache``) toggle which controls the shared cross-request cache. + """ mcp_server = getattr(self, "mcp_server", None) - if not self._not_load_actions: - tools, _ = await self.update_tool_list(mcp_server) - return tools - return [] + srv = mcp_server.get("name") if isinstance(mcp_server, dict) else mcp_server + + ttl = self.TOOL_TTL_SECS + ttl_key = self._mcp_servers_cache_key(srv or "") if ttl > 0 and srv else "" + + # TTL cache lookup + expired-entry eviction. + if ttl > 0 and ttl_key: + cached = self._ttl_tool_cache.get(ttl_key) + if cached is not None: + ts, cached_tools = cached + age = time.monotonic() - ts + if age < ttl: + await logger.adebug( + "MCP _get_tools: TTL cache hit count=%d age=%.1fs server=%r", + len(cached_tools), + age, + srv, + ) + return cached_tools + # Stale — drop it so the size check below stays tight. + self._ttl_tool_cache.pop(ttl_key, None) + + await logger.adebug("MCP _get_tools: fetching tool list server=%r", srv) + tools, _ = await self.update_tool_list(mcp_server) + await logger.adebug("MCP _get_tools: fetched %d tools server=%r", len(tools), srv) + + # TTL cache store with bounded size (FIFO eviction of the oldest entry). + if ttl > 0 and ttl_key and tools: + if len(self._ttl_tool_cache) >= self.TOOL_TTL_MAX_ENTRIES: + # dict preserves insertion order — pop the oldest entry. + oldest_key = next(iter(self._ttl_tool_cache)) + self._ttl_tool_cache.pop(oldest_key, None) + self._ttl_tool_cache[ttl_key] = (time.monotonic(), tools) + + return tools