feat: Add global variable support for MCP server headers (#11300)

* feat: Add global variable support for MCP server headers

- Add IOKeyPairInputWithVariables component for header inputs with global variable dropdown
- Integrate global variable selection in MCP Server modal
- Add variable resolution in MCP server headers (_resolve_global_variables_in_headers)
- Add variable loading and decryption in MCP API endpoint and component
- Add unit tests for variable resolution utility function

* test: Add comprehensive unit tests for IOKeyPairInputWithVariables component

- Add 15 test cases covering rendering, user interactions, and edge cases
- Test global variable dropdown functionality and selection
- Test row addition/removal and input validation
- Test duplicate key detection and error handling
- Test initialization from existing values with variable badges
- Addresses CodeRabbit review feedback for frontend test coverage

* [autofix.ci] apply automated fixes

* test: fix frontend Jest tests for IOKeyPairInputWithVariables component

- Added proper mocks for IconComponent, InputComponent, and Input
- Used explicit TypeScript types instead of 'any' for mock props
- Fixed test assertions to match actual component structure (Type key.../Type a value... placeholders)
- All 8 tests now pass without console warnings
- Tests cover: rendering, onChange callbacks, add/delete buttons, global variables toggle

* fix: restore session_scope import to module level for test mocking

The session_scope import was moved inside _process_headers method, which broke
unit tests that mock it at the module level. Restored it to module-level imports
with noqa comment to prevent ruff from removing it, and removed the duplicate
import from inside the method.

Fixes: test_database_config_used_when_no_value_config

* chore: trigger CI rebuild to test for flaky Playwright test

* Update component index

* chore: update Nvidia Remix starter project with session_scope import fix

* perf(mcp): optimize global variable loading to prevent timeouts

Only load global variables from database when headers are actually present
in the MCP server config. This avoids unnecessary database queries for
servers without headers, significantly improving performance and preventing
timeouts in tests that repeatedly connect to MCP servers.

The optimization adds a conditional check before the database query:
- has_headers = server_config.get('headers') and len(server_config.get('headers', {})) > 0
- Only queries database if has_headers is True

This resolves the cumulative delay issue where each MCP connection retry
was performing an expensive database query even when no headers were configured.

* [autofix.ci] apply automated fixes

* fix(test): update Playwright test for new header input component structure

Update MCP server Playwright test to work with the new KeyPairInputWithVariables
component that uses InputComponent with global variable support for header values.

Changes:
- Use getByPlaceholder() instead of getByTestId() for header value fields
- Header value fields now use InputComponent which doesn't expose data-testid

The new component structure wraps the value input in InputComponent to provide
global variable dropdown functionality, requiring a different selector strategy.

* chore: update Nvidia Remix starter project

Update starter project file that was modified by the automated build pipeline.

* Update component index

* fix: replace jose with jwt (#11285)

* replace jose with jwt

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* pin to lower pyjwt

* pyjwt version

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* Update component index

* Update component index

* [autofix.ci] apply automated fixes

* Update component index

* chore: trigger CI rebuild

* [autofix.ci] apply automated fixes

* refactor: remove langflow imports from lfx MCP component

Separate lfx from langflow by using the service layer pattern:

Changes:
1. Extended VariableServiceProtocol in lfx with get_all_decrypted_variables()
2. Implemented the method in langflow's DatabaseVariableService
3. Updated MCP component to use get_variable_service() instead of direct imports
4. Added comprehensive unit tests for the new service method

Benefits:
- Clean separation between lfx and langflow packages
- Uses existing service architecture pattern
- Maintains performance optimization (only loads variables when needed)
- No breaking changes to functionality

Removed direct langflow imports from mcp_component.py (auth utils, Variable model, settings service, and sqlmodel select).

Files modified:
- src/lfx/src/lfx/services/interfaces.py
- src/backend/base/langflow/services/variable/service.py
- src/lfx/src/lfx/components/models_and_agents/mcp_component.py
- src/backend/tests/unit/services/variable/test_service.py (added 3 new tests)

* fix: Handle global variables correctly for components

- Fix UUID type conversion in variable service to prevent SQLAlchemy errors
- Add type-based decryption: only decrypt CREDENTIAL variables, not GENERIC
- Improve error handling in decrypt_api_key to prevent crashes on second attempt
- Resolves 'str object has no attribute hex' error when loading global variables

This fixes issues with watsonx.ai and MCP components when using global variables.

* [autofix.ci] apply automated fixes

* Update component index

* Consider other failed decryption cases

* fix(variable-service): Fix UUID conversion and type-based variable decryption

- Convert string to UUID in get_all_decrypted_variables() to prevent SQLAlchemy errors
- Implement type-based decryption: only decrypt CREDENTIAL variables, not GENERIC
- Simplify get_all() to return stored values directly for both variable types
- Fixes watsonx component update error (400 status) when editing values

Resolves issue where editing watsonx.ai component values caused:
'Error while updating the Component' with 400 client error

* [autofix.ci] apply automated fixes

* chore: update starter project files

* fix: remove explicit value assignment to allow credential redaction

* fix: handle credential redaction in frontend and fix Playwright test hangs

- Update GlobalVariableModal to handle None credential values
- Allow credential updates without re-entering value
- Fix userSettings test by using dispatchEvent for clicks
- Add waitForTimeout after clicks to prevent hangs
- Use .first() for Fields selectors to avoid strict mode violations

* [autofix.ci] apply automated fixes

* Update component index

* [autofix.ci] apply automated fixes

* fix: improve error handling and logging for variable decryption

Address code review feedback:

1. Increase log level from debug to warning when decryption fails in
   auth/utils.py. Returning empty string silently could cause issues,
   so warning level makes failures more visible.

2. Follow established pattern in mcp.py for variable decryption:
   - Only decrypt CREDENTIAL type variables (encrypted in storage)
   - Use GENERIC type variables as-is (stored as plaintext)
   - Change from silent fallback (adebug) to explicit error (aerror)
   - This matches the pattern in get_all_decrypted_variables()

These changes make error handling more explicit and consistent across
the codebase.

* Update component index

* Update component index

* [autofix.ci] apply automated fixes

* chore: trigger CI rebuild

* [autofix.ci] apply automated fixes

* Updates to ensure backwards compatibility for encrypted generic variables

* Skip failed decryption

* Fix test

* ruff

* update starter projects

* ruff

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* comp index

* [autofix.ci] apply automated fixes

* remove unnecessary step in pandas series conversion

---------

Co-authored-by: Steve Haertel <shaertel@ca.ibm.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com>
Co-authored-by: Jordan Frazier <jordan.frazier@datastax.com>
This commit is contained in:
Steve Haertel
2026-01-20 14:03:30 -05:00
committed by GitHub
parent 50bab0a4e7
commit b3df3a7c35
28 changed files with 7105 additions and 6033 deletions

View File

@ -897,7 +897,7 @@
"filename": "src/backend/base/langflow/services/auth/utils.py",
"hashed_secret": "b894b81be94cf8fa8d7536475aaec876addf05c8",
"is_verified": false,
"line_number": 31,
"line_number": 32,
"is_secret": false
}
],
@ -1528,5 +1528,5 @@
}
]
},
"generated_at": "2026-01-08T13:01:29Z"
"generated_at": "2026-01-15T17:59:11Z"
}

View File

@ -113,5 +113,3 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
# Made with Bob

View File

@ -203,8 +203,8 @@ def calculate_text_metrics(df: pd.DataFrame, text_columns: list[str]) -> tuple[i
continue
text_series = df[col].astype(str).fillna("")
total_characters += int(text_series.str.len().sum().item())
total_words += int(text_series.str.split().str.len().sum().item())
total_characters += int(text_series.str.len().sum())
total_words += int(text_series.str.split().str.len().sum())
return total_words, total_characters

View File

@ -156,11 +156,45 @@ async def get_servers(
mcp_stdio_client = MCPStdioClient()
mcp_streamable_http_client = MCPStreamableHttpClient()
try:
# Get global variables from database for header resolution
request_variables = {}
try:
from sqlmodel import select
from langflow.services.auth import utils as auth_utils
from langflow.services.database.models.variable.model import Variable
from langflow.services.deps import get_settings_service
settings_service = get_settings_service()
# Load variables directly from database and decrypt ALL types (including CREDENTIAL)
stmt = select(Variable).where(Variable.user_id == current_user.id)
variables = list((await session.exec(stmt)).all())
# Decrypt variables based on type (following the pattern from get_all_decrypted_variables)
for variable in variables:
if variable.name and variable.value:
# Prior to v1.8, both Generic and Credential variables were encrypted.
# As such, must attempt to decrypt both types to ensure backwards-compatibility.
try:
decrypted_value = auth_utils.decrypt_api_key(
variable.value, settings_service=settings_service
)
request_variables[variable.name] = decrypted_value
except Exception as e: # noqa: BLE001
await logger.aerror(
f"Failed to decrypt credential variable '{variable.name}': {e}. "
"This credential will not be available for MCP server."
)
except Exception as e: # noqa: BLE001
await logger.awarning(f"Failed to load global variables for MCP server test: {e}")
mode, tool_list, _ = await update_tools(
server_name=server_name,
server_config=server_list["mcpServers"][server_name],
mcp_stdio_client=mcp_stdio_client,
mcp_streamable_http_client=mcp_streamable_http_client,
request_variables=request_variables,
)
server_info["mode"] = mode.lower()
server_info["toolsCount"] = len(tool_list)

File diff suppressed because one or more lines are too long

View File

@ -35,17 +35,11 @@ def encrypt_auth_settings(auth_settings: dict[str, Any] | None) -> dict[str, Any
try:
field_to_encrypt = encrypted_settings[field]
# Only encrypt if the value is not already encrypted
# Try to decrypt first - if it fails, it's not encrypted
try:
result = auth_utils.decrypt_api_key(field_to_encrypt, settings_service)
if not result:
msg = f"Failed to decrypt field {field}"
raise ValueError(msg)
# If decrypt succeeds, it's already encrypted
# Check if it's already encrypted using is_encrypted helper
if is_encrypted(field_to_encrypt):
logger.debug(f"Field {field} is already encrypted")
except (ValueError, TypeError, KeyError, InvalidToken):
# If decrypt fails, the value is plaintext and needs encryption
else:
# Not encrypted, encrypt it
encrypted_value = auth_utils.encrypt_api_key(field_to_encrypt, settings_service)
encrypted_settings[field] = encrypted_value
except (ValueError, TypeError, KeyError) as e:
@ -111,10 +105,14 @@ def is_encrypted(value: str) -> bool:
settings_service = get_settings_service()
try:
# Try to decrypt - if it succeeds, it's encrypted
auth_utils.decrypt_api_key(value, settings_service)
# Try to decrypt - if it succeeds and returns a different value, it's encrypted
decrypted = auth_utils.decrypt_api_key(value, settings_service)
# If decryption returns empty string, it's encrypted with wrong key
if not decrypted:
return True
# If it returns a different value, it's successfully decrypted (was encrypted)
# If it returns the same value, something unexpected happened
return decrypted != value # noqa: TRY300
except (ValueError, TypeError, KeyError, InvalidToken):
# If decryption fails, it's not encrypted
return False
else:
# If decryption fails with exception, assume it's encrypted but can't be decrypted
return True

View File

@ -622,17 +622,20 @@ def encrypt_api_key(api_key: str, settings_service: SettingsService):
def decrypt_api_key(encrypted_api_key: str, settings_service: SettingsService):
"""Decrypt the provided encrypted API key using Fernet decryption.
This function first attempts to decrypt the API key by encoding it,
assuming it is a properly encoded string. If that fails, it logs a detailed
debug message including the exception information and retries decryption
using the original string input.
This function supports both encrypted and plain text values. It first attempts
to decrypt the API key by encoding it, assuming it is a properly encrypted string.
If that fails, it retries decryption using the original string input. If both
decryption attempts fail, it checks if the value looks like a Fernet token
(starts with "gAAAAA"). If it does, it's likely encrypted with a different key
and returns empty string. Otherwise, it assumes plain text and returns as-is.
Args:
encrypted_api_key (str): The encrypted API key.
encrypted_api_key (str): The encrypted API key or plain text value.
settings_service (SettingsService): Service providing authentication settings.
Returns:
str: The decrypted API key, or an empty string if decryption cannot be performed.
str: The decrypted API key, the original value if plain text, or empty string
if it's encrypted with a different key.
"""
fernet = get_fernet(settings_service)
if isinstance(encrypted_api_key, str):
@ -644,8 +647,25 @@ def decrypt_api_key(encrypted_api_key: str, settings_service: SettingsService):
"Retrying decryption using the raw string input.",
primary_exception,
)
return fernet.decrypt(encrypted_api_key).decode()
return ""
try:
return fernet.decrypt(encrypted_api_key).decode()
except Exception as secondary_exception: # noqa: BLE001
# Check if this looks like a Fernet token (base64 encoded, starts with gAAAAA)
if encrypted_api_key.startswith("gAAAAA"):
logger.warning(
"Failed to decrypt stored value (likely encrypted with different key). "
"Error: %s. Returning empty string.",
secondary_exception,
)
return ""
# Assume the value is plain text and return it as-is
logger.debug(
"Value does not appear to be encrypted (no Fernet token signature). Returning value as plain text."
)
return encrypted_api_key
msg = "Unexpected variable type. Expected string"
raise ValueError(msg)
# MCP-specific authentication functions that always behave as if skip_auth_auto_login is True

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import UUID
from lfx.log.logger import logger
from sqlmodel import select
@ -15,7 +16,6 @@ from langflow.services.variable.constants import CREDENTIAL_TYPE, GENERIC_TYPE
if TYPE_CHECKING:
from collections.abc import Sequence
from uuid import UUID
from lfx.services.settings.service import SettingsService
from sqlmodel.ext.asyncio.session import AsyncSession
@ -176,30 +176,68 @@ class DatabaseVariableService(VariableService, Service):
)
raise TypeError(msg)
# we decrypt the value
return auth_utils.decrypt_api_key(variable.value, settings_service=self.settings_service)
# Only decrypt CREDENTIAL type variables; GENERIC variables are stored as plain text
if variable.type == CREDENTIAL_TYPE:
return auth_utils.decrypt_api_key(variable.value, settings_service=self.settings_service)
# GENERIC type - return as-is
return variable.value
async def get_all(self, user_id: UUID | str, session: AsyncSession) -> list[VariableRead]:
stmt = select(Variable).where(Variable.user_id == user_id)
variables = list((await session.exec(stmt)).all())
# For variables of type 'Generic', attempt to decrypt the value.
# If decryption fails, assume the value is already plaintext.
variables_read = []
for variable in variables:
value = None
if variable.type == GENERIC_TYPE:
try:
value = auth_utils.decrypt_api_key(variable.value, settings_service=self.settings_service)
except Exception as e: # noqa: BLE001
await logger.adebug(
f"Decryption of {variable.type} failed for variable '{variable.name}': {e}. Assuming plaintext."
)
value = variable.value
value = auth_utils.decrypt_api_key(variable.value, settings_service=self.settings_service)
if not value:
# If decryption fails (likely due to encryption by different key), skip this variable
continue
# Model validate will set value to None if credential type
variable_read = VariableRead.model_validate(variable, from_attributes=True)
variable_read.value = value
if variable.type == GENERIC_TYPE:
variable_read.value = value
variables_read.append(variable_read)
return variables_read
async def get_all_decrypted_variables(
self,
user_id: UUID | str,
session: AsyncSession,
) -> dict[str, str]:
"""Get all variables for a user with decrypted values.
Args:
user_id: The user ID to get variables for
session: Database session
Returns:
Dictionary mapping variable names to decrypted values
"""
# Convert string to UUID if needed for SQLAlchemy query
user_id_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
stmt = select(Variable).where(Variable.user_id == user_id_uuid)
variables = (await session.exec(stmt)).all()
result = {}
for var in variables:
if var.name and var.value:
try:
decrypted_value = auth_utils.decrypt_api_key(var.value, settings_service=self.settings_service)
except Exception as e: # noqa: BLE001
await logger.awarning(f"Decryption failed for variable '{var.name}': {e}. Skipping")
continue
if not decrypted_value:
await logger.awarning(f"Decryption returned empty for variable '{var.name}'. Skipping")
continue
result[var.name] = decrypted_value
return result
async def get_variable_by_id(
self,
user_id: UUID | str,
@ -229,6 +267,15 @@ class DatabaseVariableService(VariableService, Service):
if not variable:
msg = f"{name} variable not found."
raise ValueError(msg)
# Validate that GENERIC variables don't start with Fernet signature
if variable.type == GENERIC_TYPE and value.startswith("gAAAAA"):
msg = (
f"Generic variable '{name}' cannot start with 'gAAAAA' as this is reserved "
"for encrypted values. Please use a different value."
)
raise ValueError(msg)
# Only encrypt CREDENTIAL_TYPE variables
if variable.type == CREDENTIAL_TYPE:
variable.value = auth_utils.encrypt_api_key(value, settings_service=self.settings_service)
@ -302,6 +349,14 @@ class DatabaseVariableService(VariableService, Service):
type_: str = CREDENTIAL_TYPE,
session: AsyncSession,
):
# Validate that GENERIC variables don't start with Fernet signature
if type_ == GENERIC_TYPE and value.startswith("gAAAAA"):
msg = (
f"Generic variable '{name}' cannot start with 'gAAAAA' as this is reserved "
"for encrypted values. Please use a different value."
)
raise ValueError(msg)
# Only encrypt CREDENTIAL_TYPE variables
encrypted_value = (
auth_utils.encrypt_api_key(value, settings_service=self.settings_service)

View File

@ -1,3 +1 @@
"""Tests package for langflow."""
# Made with Bob

View File

@ -1,3 +1 @@
"""Database tests package."""
# Made with Bob

View File

@ -1,3 +1 @@
"""Database models tests package."""
# Made with Bob

View File

@ -1,3 +1 @@
"""Transaction models tests package."""
# Made with Bob

View File

@ -117,6 +117,3 @@ async def test_code_key_not_saved_to_database():
assert "code" not in serialized_inputs
assert "param1" in serialized_inputs
assert "param2" in serialized_inputs
# Made with Bob

View File

@ -193,6 +193,127 @@ class TestHeaderValidation:
assert result == {"safe-header": "safe-value"}
class TestGlobalVariableResolution:
"""Test global variable resolution in headers."""
def test_resolve_global_variables_basic(self):
"""Test basic global variable resolution in headers."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {"x-api-key": "MY_API_KEY", "authorization": "MY_TOKEN"}
request_variables = {"MY_API_KEY": "secret-key-123", "MY_TOKEN": "token-456"} # pragma: allowlist secret
result = _resolve_global_variables_in_headers(headers, request_variables)
assert result == {"x-api-key": "secret-key-123", "authorization": "token-456"}
def test_resolve_global_variables_no_variables(self):
"""Test header resolution when no request_variables provided."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {"x-api-key": "MY_API_KEY", "content-type": "application/json"}
# No request_variables
result = _resolve_global_variables_in_headers(headers, None)
assert result == headers
# Empty request_variables
result = _resolve_global_variables_in_headers(headers, {})
assert result == headers
def test_resolve_global_variables_partial_match(self):
"""Test resolution when only some headers match variables."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {
"x-api-key": "MY_API_KEY", # matches
"authorization": "static-token", # no match
"x-custom": "MY_CUSTOM", # matches
}
request_variables = {"MY_API_KEY": "resolved-key", "MY_CUSTOM": "custom-value"} # pragma: allowlist secret
result = _resolve_global_variables_in_headers(headers, request_variables)
assert result == {
"x-api-key": "resolved-key",
"authorization": "static-token",
"x-custom": "custom-value",
}
def test_resolve_global_variables_non_string_values(self):
"""Test that non-string header values are preserved."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {"x-api-key": "MY_KEY", "x-number": 123, "x-none": None}
request_variables = {"MY_KEY": "resolved"}
result = _resolve_global_variables_in_headers(headers, request_variables)
assert result == {"x-api-key": "resolved", "x-number": 123, "x-none": None}
def test_process_headers_with_request_variables_dict(self):
"""Test _process_headers with dict input and request_variables."""
headers = {"x-api-key": "MY_API_KEY", "content-type": "application/json"}
request_variables = {"MY_API_KEY": "secret-123"} # pragma: allowlist secret
result = _process_headers(headers, request_variables)
# Should resolve variable and normalize to lowercase
assert result == {"x-api-key": "secret-123", "content-type": "application/json"}
def test_process_headers_with_request_variables_list(self):
"""Test _process_headers with list input and request_variables."""
headers = [
{"key": "x-api-key", "value": "MY_API_KEY"},
{"key": "Content-Type", "value": "application/json"},
]
request_variables = {"MY_API_KEY": "secret-123"} # pragma: allowlist secret
result = _process_headers(headers, request_variables)
# Should resolve variable and normalize to lowercase
assert result == {"x-api-key": "secret-123", "content-type": "application/json"}
def test_process_headers_without_request_variables(self):
"""Test _process_headers maintains backward compatibility without request_variables."""
headers = {"X-API-Key": "static-value", "Content-Type": "application/json"}
result = _process_headers(headers)
# Should just normalize without resolution
assert result == {"x-api-key": "static-value", "content-type": "application/json"}
def test_resolve_global_variables_case_sensitive_matching(self):
"""Test that variable name matching is case-sensitive."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {"x-api-key": "my_api_key", "x-token": "MY_API_KEY"}
request_variables = {"MY_API_KEY": "resolved-uppercase"} # pragma: allowlist secret
result = _resolve_global_variables_in_headers(headers, request_variables)
# Only exact match should be resolved
assert result == {"x-api-key": "my_api_key", "x-token": "resolved-uppercase"}
def test_resolve_global_variables_empty_headers(self):
"""Test resolution with empty headers."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
result = _resolve_global_variables_in_headers({}, {"VAR": "value"})
assert result == {}
def test_resolve_global_variables_special_characters(self):
"""Test resolution with special characters in values."""
from lfx.base.mcp.util import _resolve_global_variables_in_headers
headers = {"authorization": "MY_TOKEN"}
request_variables = {"MY_TOKEN": "Bearer token-with-special!@#$%^&*()_+-=[]{}|;:,.<>?"}
result = _resolve_global_variables_in_headers(headers, request_variables)
assert result["authorization"] == "Bearer token-with-special!@#$%^&*()_+-=[]{}|;:,.<>?"
class TestStreamableHTTPHeaderIntegration:
"""Integration test to verify headers are properly passed through the entire StreamableHTTP flow."""

View File

@ -0,0 +1,204 @@
"""Test decrypt_api_key function with encrypted, plain text, and wrong key scenarios."""
from unittest.mock import Mock
import pytest
from cryptography.fernet import Fernet
from langflow.services.auth.mcp_encryption import is_encrypted
from langflow.services.auth.utils import decrypt_api_key, encrypt_api_key
from pydantic import SecretStr
@pytest.fixture
def mock_settings_service():
"""Mock settings service with a valid Fernet key."""
mock_service = Mock()
valid_key = Fernet.generate_key()
valid_key_str = valid_key.decode("utf-8")
secret_key_obj = SecretStr(valid_key_str)
mock_service.auth_settings.SECRET_KEY = secret_key_obj
return mock_service
@pytest.fixture
def different_settings_service():
"""Mock settings service with a different Fernet key."""
mock_service = Mock()
# Generate a different key
different_key = Fernet.generate_key()
different_key_str = different_key.decode("utf-8")
secret_key_obj = SecretStr(different_key_str)
mock_service.auth_settings.SECRET_KEY = secret_key_obj
return mock_service
class TestDecryptApiKey:
"""Test decrypt_api_key function behavior."""
def test_decrypt_encrypted_value_success(self, mock_settings_service):
"""Test successful decryption of an encrypted value."""
original_value = "my-secret-api-key-12345"
# Encrypt the value
encrypted_value = encrypt_api_key(original_value, mock_settings_service)
# Verify it's encrypted (should start with gAAAAA)
assert encrypted_value.startswith("gAAAAA")
assert encrypted_value != original_value
# Decrypt and verify
decrypted_value = decrypt_api_key(encrypted_value, mock_settings_service)
assert decrypted_value == original_value
def test_decrypt_plain_text_value(self, mock_settings_service):
"""Test that plain text values are returned as-is."""
plain_text_value = "plain-text-api-key"
# Should return the same value
result = decrypt_api_key(plain_text_value, mock_settings_service)
assert result == plain_text_value
def test_decrypt_with_wrong_key_returns_empty(self, mock_settings_service, different_settings_service):
"""Test that encrypted values with wrong key return empty string."""
original_value = "my-secret-api-key-12345"
# Encrypt with one key
encrypted_value = encrypt_api_key(original_value, mock_settings_service)
# Verify it's encrypted
assert encrypted_value.startswith("gAAAAA")
# Try to decrypt with different key - should return empty string
result = decrypt_api_key(encrypted_value, different_settings_service)
assert result == ""
def test_decrypt_empty_string(self, mock_settings_service):
"""Test decryption of empty string."""
result = decrypt_api_key("", mock_settings_service)
assert result == ""
def test_decrypt_special_characters_plain_text(self, mock_settings_service):
"""Test plain text with special characters."""
special_value = "api-key-with-special!@#$%^&*()"
result = decrypt_api_key(special_value, mock_settings_service)
assert result == special_value
def test_decrypt_numeric_string_plain_text(self, mock_settings_service):
"""Test plain text numeric string."""
numeric_value = "1234567890"
result = decrypt_api_key(numeric_value, mock_settings_service)
assert result == numeric_value
def test_decrypt_url_plain_text(self, mock_settings_service):
"""Test plain text URL."""
url_value = "https://api.example.com/v1/key"
result = decrypt_api_key(url_value, mock_settings_service)
assert result == url_value
def test_decrypt_base64_like_but_not_fernet(self, mock_settings_service):
"""Test base64-like string that's not a Fernet token."""
# Base64 string that doesn't start with gAAAAA
base64_value = "aGVsbG8gd29ybGQ=" # "hello world" in base64
result = decrypt_api_key(base64_value, mock_settings_service)
assert result == base64_value
def test_decrypt_long_encrypted_value(self, mock_settings_service):
"""Test decryption of a long encrypted value."""
long_value = "a" * 1000 # 1000 character string
encrypted_value = encrypt_api_key(long_value, mock_settings_service)
decrypted_value = decrypt_api_key(encrypted_value, mock_settings_service)
assert decrypted_value == long_value
def test_decrypt_unicode_plain_text(self, mock_settings_service):
"""Test plain text with unicode characters."""
unicode_value = "api-key-with-émojis-🔑-and-中文"
result = decrypt_api_key(unicode_value, mock_settings_service)
assert result == unicode_value
def test_decrypt_encrypted_unicode(self, mock_settings_service):
"""Test encryption and decryption of unicode characters."""
unicode_value = "secret-🔐-key-密钥"
encrypted_value = encrypt_api_key(unicode_value, mock_settings_service)
decrypted_value = decrypt_api_key(encrypted_value, mock_settings_service)
assert decrypted_value == unicode_value
def test_fernet_token_signature_detection(self, mock_settings_service, different_settings_service):
"""Test that Fernet token signature (gAAAAA) is properly detected."""
original_value = "test-value"
# Encrypt with one key
encrypted_value = encrypt_api_key(original_value, mock_settings_service)
# Verify it has the Fernet signature
assert encrypted_value.startswith("gAAAAA")
# Decrypt with wrong key should return empty (not the encrypted value)
result = decrypt_api_key(encrypted_value, different_settings_service)
assert result == ""
assert result != encrypted_value
# Made with Bob
class TestIsEncrypted:
"""Test is_encrypted helper function."""
def test_is_encrypted_with_encrypted_value(self, mock_settings_service):
"""Test that encrypted values are correctly identified."""
original_value = "my-secret-key"
encrypted_value = encrypt_api_key(original_value, mock_settings_service)
# Should be identified as encrypted
assert is_encrypted(encrypted_value)
def test_is_encrypted_with_plain_text(self, mock_settings_service): # noqa: ARG002
"""Test that plain text values are not identified as encrypted."""
plain_text = "plain-text-value"
# Should not be identified as encrypted
assert not is_encrypted(plain_text)
def test_is_encrypted_with_empty_string(self, mock_settings_service): # noqa: ARG002
"""Test that empty string is not identified as encrypted."""
assert not is_encrypted("")
def test_is_encrypted_with_none(self, mock_settings_service): # noqa: ARG002
"""Test that None is handled gracefully."""
# is_encrypted expects a string, but let's test edge case
assert not is_encrypted(None) if None else True # Will short-circuit
def test_is_encrypted_with_base64_not_fernet(self, mock_settings_service): # noqa: ARG002
"""Test that base64 strings without Fernet signature are not identified as encrypted."""
base64_value = "aGVsbG8gd29ybGQ=" # "hello world" in base64
# Should not be identified as encrypted (doesn't start with gAAAAA)
assert not is_encrypted(base64_value)
def test_is_encrypted_with_wrong_key(self, mock_settings_service):
"""Test that values encrypted with different key are still identified as encrypted."""
original_value = "my-secret-key"
# Encrypt with one key
encrypted_value = encrypt_api_key(original_value, mock_settings_service)
# Should still be identified as encrypted even with different settings service
# (because it has the Fernet signature)
assert is_encrypted(encrypted_value)
def test_is_encrypted_with_fernet_signature_prefix(self, mock_settings_service): # noqa: ARG002
"""Test that strings starting with gAAAAA are identified as encrypted."""
# Create a fake Fernet-like string (won't decrypt but has signature)
fake_encrypted = "gAAAAABfakeencryptedvalue123456789"
# Should be identified as encrypted based on signature
assert is_encrypted(fake_encrypted)

View File

@ -240,3 +240,96 @@ async def test_create_variable(service, session: AsyncSession):
assert result.type == CREDENTIAL_TYPE
assert isinstance(result.created_at, datetime)
assert result.updated_at is None # Should be None on creation
async def test_get_all_decrypted_variables(service, session: AsyncSession):
"""Test get_all_decrypted_variables returns all variables with decrypted values."""
user_id = uuid4()
# Create multiple variables with different types
await service.create_variable(user_id, "API_KEY_1", "secret_value_1", type_=CREDENTIAL_TYPE, session=session)
await service.create_variable(user_id, "API_KEY_2", "secret_value_2", type_=CREDENTIAL_TYPE, session=session)
await service.create_variable(user_id, "GENERIC_VAR", "plain_value", type_="GENERIC", session=session)
# Get all decrypted variables
result = await service.get_all_decrypted_variables(user_id, session=session)
# Verify all variables are returned
assert len(result) == 3
assert "API_KEY_1" in result
assert "API_KEY_2" in result
assert "GENERIC_VAR" in result
# Verify values are decrypted
assert result["API_KEY_1"] == "secret_value_1" # pragma: allowlist secret
assert result["API_KEY_2"] == "secret_value_2" # pragma: allowlist secret
assert result["GENERIC_VAR"] == "plain_value"
async def test_get_all_decrypted_variables__empty(service, session: AsyncSession):
"""Test get_all_decrypted_variables returns empty dict when no variables exist."""
user_id = uuid4()
result = await service.get_all_decrypted_variables(user_id, session=session)
assert result == {}
assert isinstance(result, dict)
async def test_get_all_decrypted_variables__decryption_failure(service, session: AsyncSession):
"""Test get_all_decrypted_variables handles decryption failures gracefully."""
user_id = uuid4()
# Create a variable
await service.create_variable(user_id, "TEST_VAR", "test_value", session=session)
# Mock decryption to fail
with patch("langflow.services.auth.utils.decrypt_api_key") as mock_decrypt:
mock_decrypt.side_effect = Exception("Decryption failed")
result = await service.get_all_decrypted_variables(user_id, session=session)
# Should skip variables that fail decryption
assert "TEST_VAR" not in result
assert result == {}
async def test_create_generic_variable_with_fernet_signature_fails(service, session: AsyncSession):
"""Test that creating a GENERIC variable starting with gAAAAA fails."""
user_id = uuid4()
with pytest.raises(ValueError, match="cannot start with 'gAAAAA'"):
await service.create_variable(
user_id, "TEST_VAR", "gAAAAABthis-looks-like-encrypted-but-is-generic", type_="Generic", session=session
)
async def test_update_generic_variable_with_fernet_signature_fails(service, session: AsyncSession):
"""Test that updating a GENERIC variable to start with gAAAAA fails."""
user_id = uuid4()
# Create a normal generic variable
await service.create_variable(user_id, "TEST_VAR", "normal_value", type_="Generic", session=session)
# Try to update it to a value starting with gAAAAA
with pytest.raises(ValueError, match="cannot start with 'gAAAAA'"):
await service.update_variable(user_id, "TEST_VAR", "gAAAAABthis-looks-like-encrypted", session=session)
async def test_create_credential_variable_with_fernet_signature_succeeds(service, session: AsyncSession):
"""Test that CREDENTIAL variables can have values that look like Fernet tokens (they get encrypted anyway)."""
user_id = uuid4()
# This should succeed because CREDENTIAL types are encrypted
variable = await service.create_variable(
user_id,
"TEST_CRED",
"gAAAAABsome-value", # This will be encrypted, so it's fine
type_="Credential",
session=session,
)
assert variable is not None
assert variable.name == "TEST_CRED"
# The value should be encrypted (different from input)
assert variable.value != "gAAAAABsome-value"

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -144,41 +144,50 @@ export default function GlobalVariableModal({
PROVIDER_VARIABLE_MAPPING,
).includes(initialData.name);
updateVariable(
{
id: initialData.id,
name: key,
value: value,
default_fields: fields,
},
{
onSuccess: (res) => {
const { name } = res;
setKey("");
setValue("");
setType("Credential");
setFields([]);
setOpen(false);
// Only include value in update if it has been changed (not empty for credentials)
const updateData: {
id: string;
name: string;
value?: string;
default_fields?: string[];
} = {
id: initialData.id,
name: key,
default_fields: fields,
};
setSuccessData({
title: `Variable ${name} updated successfully`,
});
},
onError: (error) => {
const responseError = error as ResponseErrorDetailAPI;
const errorMessage =
responseError?.response?.data?.detail ??
"An unexpected error occurred while updating the variable. Please try again.";
// Only include value if it's been provided (for credentials, empty means unchanged)
if (value) {
updateData.value = value;
}
setErrorData({
title: isModelProviderVariable
? "Invalid API Key"
: "Error updating variable",
list: [errorMessage],
});
},
updateVariable(updateData, {
onSuccess: (res) => {
const { name } = res;
setKey("");
setValue("");
setType("Credential");
setFields([]);
setOpen(false);
setSuccessData({
title: `Variable ${name} updated successfully`,
});
},
);
onError: (error) => {
const responseError = error as ResponseErrorDetailAPI;
const errorMessage =
responseError?.response?.data?.detail ??
"An unexpected error occurred while updating the variable. Please try again.";
setErrorData({
title: isModelProviderVariable
? "Invalid API Key"
: "Error updating variable",
list: [errorMessage],
});
},
});
}
}
@ -279,7 +288,7 @@ export default function GlobalVariableModal({
submit={{
label: `${initialData ? "Update" : "Save"} Variable`,
dataTestId: "save-variable-btn",
disabled: !key || !value,
disabled: !key || (!value && !(initialData && type === "Credential")),
}}
/>
</BaseModal>

View File

@ -0,0 +1,230 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import IOKeyPairInputWithVariables from "../key-pair-input-with-variables";
// Mock the useGetGlobalVariables hook
jest.mock(
"@/controllers/API/queries/variables/use-get-global-variables",
() => ({
useGetGlobalVariables: jest.fn(() => ({
data: [
{ name: "API_KEY_1", type: "CREDENTIAL" },
{ name: "API_KEY_2", type: "GENERIC" },
{ name: "TOKEN", type: "CREDENTIAL" },
],
isLoading: false,
})),
}),
);
// Mock nanoid
jest.mock("nanoid", () => ({
nanoid: jest.fn(() => "test-id-123"),
}));
// Mock IconComponent
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({
name,
className,
...props
}: {
name: string;
className?: string;
}) => (
<span data-testid={`icon-${name}`} className={className} {...props}>
{name}
</span>
),
}));
// Mock InputComponent - filter out custom props to avoid React warnings
jest.mock(
"@/components/core/parameterRenderComponent/components/inputComponent",
() => ({
__esModule: true,
default: ({
value,
onChange,
placeholder,
id,
disabled,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
id?: string;
disabled?: boolean;
}) => (
<input
data-testid={id || "input-component"}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
),
}),
);
// Mock Input component from shadcn/ui
jest.mock("@/components/ui/input", () => ({
Input: ({
placeholder,
value,
onChange,
disabled,
...props
}: {
placeholder?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
}) => (
<input
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
{...props}
/>
),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("IOKeyPairInputWithVariables", () => {
const defaultProps = {
value: [{ key: "", value: "", id: "1", error: false }],
onChange: jest.fn(),
duplicateKey: false,
isList: true,
isInputField: true,
testId: "test-input",
enableGlobalVariables: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
it("renders with initial empty row", () => {
render(<IOKeyPairInputWithVariables {...defaultProps} />, {
wrapper: createWrapper(),
});
const keyInputs = screen.getAllByPlaceholderText("Type key...");
expect(keyInputs).toHaveLength(1);
});
it("renders with existing key-value pairs", () => {
const props = {
...defaultProps,
value: [
{ key: "x-api-key", value: "API_KEY_1", id: "1", error: false },
{ key: "authorization", value: "Bearer token", id: "2", error: false },
],
};
render(<IOKeyPairInputWithVariables {...defaultProps} />, {
wrapper: createWrapper(),
});
expect(screen.getAllByPlaceholderText("Type key...")).toHaveLength(1);
});
it("calls onChange when key input changes", () => {
const onChange = jest.fn();
const props = { ...defaultProps, onChange };
render(<IOKeyPairInputWithVariables {...props} />, {
wrapper: createWrapper(),
});
const keyInput = screen.getByPlaceholderText("Type key...");
fireEvent.change(keyInput, { target: { value: "x-api-key" } });
expect(onChange).toHaveBeenCalled();
});
it("renders add button for last row when isList is true", () => {
render(<IOKeyPairInputWithVariables {...defaultProps} />, {
wrapper: createWrapper(),
});
const plusIcon = screen.getByTestId("icon-Plus");
expect(plusIcon).toBeInTheDocument();
});
it("renders delete button for non-last rows", () => {
const props = {
...defaultProps,
value: [
{ key: "key1", value: "value1", id: "1", error: false },
{ key: "key2", value: "value2", id: "2", error: false },
],
};
render(<IOKeyPairInputWithVariables {...props} />, {
wrapper: createWrapper(),
});
const xIcon = screen.getByTestId("icon-X");
expect(xIcon).toBeInTheDocument();
});
it("does not render variable input when enableGlobalVariables is false", () => {
const props = { ...defaultProps, enableGlobalVariables: false };
render(<IOKeyPairInputWithVariables {...props} />, {
wrapper: createWrapper(),
});
// Should render regular input instead of InputComponent
const inputs = screen.getAllByPlaceholderText(/Type/);
expect(inputs.length).toBeGreaterThan(0);
});
it("handles empty value array gracefully", () => {
const props = { ...defaultProps, value: [] };
const { container } = render(<IOKeyPairInputWithVariables {...props} />, {
wrapper: createWrapper(),
});
// Should render without crashing
expect(container).toBeInTheDocument();
});
it("marks duplicate keys as errors when duplicateKey is true", () => {
const onChange = jest.fn();
const props = {
...defaultProps,
onChange,
duplicateKey: true,
value: [
{ key: "x-api-key", value: "value1", id: "1", error: false },
{ key: "x-api-key", value: "value2", id: "2", error: false },
],
};
render(<IOKeyPairInputWithVariables {...props} />, {
wrapper: createWrapper(),
});
// Component should render
expect(screen.getAllByPlaceholderText("Type key...")).toHaveLength(2);
});
});
// Made with Bob

View File

@ -0,0 +1,195 @@
import { nanoid } from "nanoid";
import { useEffect, useState } from "react";
import IconComponent from "../../../../../components/common/genericIconComponent";
import InputComponent from "../../../../../components/core/parameterRenderComponent/components/inputComponent";
import { Input } from "../../../../../components/ui/input";
import { useGetGlobalVariables } from "../../../../../controllers/API/queries/variables";
import { classNames } from "../../../../../utils/utils";
export type KeyPairRow = {
id: string;
key: string;
value: string;
error: boolean;
};
export type IOKeyPairInputWithVariablesProps = {
value: KeyPairRow[];
onChange: (value: KeyPairRow[]) => void;
duplicateKey: boolean;
isList: boolean;
isInputField?: boolean;
testId?: string;
enableGlobalVariables?: boolean;
};
const IOKeyPairInputWithVariables = ({
value,
onChange,
duplicateKey,
isList = true,
isInputField,
testId,
enableGlobalVariables = false,
}: IOKeyPairInputWithVariablesProps) => {
const { data: globalVariables = [] } = useGetGlobalVariables();
const [selectedGlobalVariables, setSelectedGlobalVariables] = useState<
Record<string, string>
>({});
const globalVariableOptions = globalVariables.map((gv) => gv.name);
// Initialize selectedGlobalVariables when value changes or global variables load
useEffect(() => {
if (globalVariables.length > 0 && value.length > 0) {
const initialSelected: Record<string, string> = {};
value.forEach((item) => {
// Check if the value matches a global variable name
if (globalVariableOptions.includes(item.value)) {
initialSelected[item.id] = item.value;
}
});
setSelectedGlobalVariables(initialSelected);
}
}, [globalVariables.length, value.length]);
const handleKeyChange = (id: string, newKey: string) => {
const item = value.find((item) => item.id === id);
if (item) {
const isDuplicate =
value.filter((kv) => kv.id !== id && kv.key === newKey).length > 0;
const newValue = value.map((row) =>
row.id === id ? { ...row, key: newKey, error: isDuplicate } : row,
);
onChange(newValue);
}
};
const handleValueChange = (id: string, newValue: string) => {
const item = value.find((item) => item.id === id);
if (item) {
// Keep error state for value changes
const newValues = value.map((row) =>
row.id === id ? { ...row, value: newValue } : row,
);
onChange(newValues);
}
};
const handleGlobalVariableSelect = (id: string, variableName: string) => {
setSelectedGlobalVariables((prev) => ({
...prev,
[id]: variableName,
}));
// Update the value with the global variable reference
handleValueChange(id, variableName);
};
const handleAddRow = () => {
const newValue = [
...value,
{ key: "", value: "", id: nanoid(), error: false },
];
onChange(newValue);
};
const handleDeleteRow = (item: KeyPairRow) => {
const seen = new Set<string>();
const newValue = value
.filter((value) => value.id !== item.id)
.map((row) => {
const isDuplicate = row.key !== "" && seen.has(row.key);
seen.add(row.key);
return { ...row, error: isDuplicate };
});
onChange(newValue);
// Clean up selected global variable for this row
setSelectedGlobalVariables((prev) => {
const newState = { ...prev };
delete newState[item.id];
return newState;
});
};
return (
<div className={classNames("flex h-full flex-col gap-3")}>
{value.map((item, idx) => {
return (
<div key={item.id} className="flex w-full gap-2">
<Input
type="text"
value={item.key.trim()}
className={classNames(item.error ? "input-invalid" : "")}
placeholder={
item.error ? "Duplicate or empty key" : "Type key..."
}
onChange={(event) => handleKeyChange(item.id, event.target.value)}
disabled={!isInputField}
data-testid={testId ? `${testId}-key-${idx}` : undefined}
/>
{enableGlobalVariables ? (
<InputComponent
value={item.value}
onChange={(newValue) => handleValueChange(item.id, newValue)}
disabled={!isInputField}
placeholder="Type a value..."
selectedOption={selectedGlobalVariables[item.id] || ""}
setSelectedOption={(option) =>
handleGlobalVariableSelect(item.id, option)
}
options={globalVariableOptions}
optionsPlaceholder="Search global variables..."
optionsIcon="Globe"
nodeStyle
password={false}
id={`${testId}-value-${idx}`}
editNode={false}
/>
) : (
<Input
type="text"
value={item.value}
placeholder="Type a value..."
onChange={(event) =>
handleValueChange(item.id, event.target.value)
}
disabled={!isInputField}
data-testid={testId ? `${testId}-value-${idx}` : undefined}
/>
)}
{isList && isInputField && idx === value.length - 1 ? (
<button
type="button"
onClick={handleAddRow}
data-testid={testId ? `${testId}-plus-btn-0` : undefined}
>
<IconComponent
name="Plus"
className={"h-4 w-4 hover:text-accent-foreground"}
/>
</button>
) : isList && isInputField ? (
<button
type="button"
onClick={() => handleDeleteRow(item)}
data-testid={testId ? `${testId}-minus-btn-${idx}` : undefined}
>
<IconComponent
name="X"
className="h-4 w-4 hover:text-status-red"
/>
</button>
) : (
""
)}
</div>
);
})}
</div>
);
};
export default IOKeyPairInputWithVariables;

View File

@ -26,6 +26,7 @@ import BaseModal from "@/modals/baseModal";
import IOKeyPairInput, {
KeyPairRow,
} from "@/modals/IOModal/components/IOFieldView/components/key-pair-input";
import IOKeyPairInputWithVariables from "@/modals/IOModal/components/IOFieldView/components/key-pair-input-with-variables";
import type { MCPServerType } from "@/types/mcp";
import { extractMcpServersFromJson } from "@/utils/mcpUtils";
import { parseString } from "@/utils/stringManipulation";
@ -167,9 +168,15 @@ export default function AddMcpServerModal({
env: keyPairRowToObject(stdioEnv),
});
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
return [...old, { name, toolsCount: 0 }];
});
await queryClient.setQueryData(
["useGetMCPServers"],
(old: unknown) => {
return [
...(Array.isArray(old) ? old : []),
{ name, toolsCount: 0 },
];
},
);
}
onSuccess?.(name);
setOpen(false);
@ -178,8 +185,10 @@ export default function AddMcpServerModal({
setStdioArgs([""]);
setStdioEnv([{ key: "", value: "", id: nanoid(), error: false }]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to add MCP server.",
);
}
return;
}
@ -209,9 +218,15 @@ export default function AddMcpServerModal({
headers: keyPairRowToObject(httpHeaders),
});
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
return [...old, { name, toolsCount: 0 }];
});
await queryClient.setQueryData(
["useGetMCPServers"],
(old: unknown) => {
return [
...(Array.isArray(old) ? old : []),
{ name, toolsCount: 0 },
];
},
);
}
onSuccess?.(name);
setOpen(false);
@ -220,8 +235,10 @@ export default function AddMcpServerModal({
setHttpEnv([{ key: "", value: "", id: nanoid(), error: false }]);
setHttpHeaders([{ key: "", value: "", id: nanoid(), error: false }]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to add MCP server.",
);
}
return;
}
@ -236,8 +253,8 @@ export default function AddMcpServerModal({
"lowercase",
]).slice(0, MAX_MCP_SERVER_NAME_LENGTH),
}));
} catch (e: any) {
setError(e.message || "Invalid input");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Invalid input");
return;
}
if (servers.length === 0) {
@ -247,9 +264,9 @@ export default function AddMcpServerModal({
try {
await Promise.all(servers.map((server) => modifyMCPServer(server)));
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
await queryClient.setQueryData(["useGetMCPServers"], (old: unknown) => {
return [
...old,
...(Array.isArray(old) ? old : []),
...servers.map((server) => ({
name: server.name,
toolsCount: 0,
@ -261,8 +278,12 @@ export default function AddMcpServerModal({
setOpen(false);
setJsonValue("");
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add one or more MCP servers.");
} catch (err: unknown) {
setError(
err instanceof Error
? err.message
: "Failed to add one or more MCP servers.",
);
}
}
@ -435,13 +456,14 @@ export default function AddMcpServerModal({
</div>
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Headers</Label>
<IOKeyPairInput
<IOKeyPairInputWithVariables
value={httpHeaders}
onChange={setHttpHeaders}
duplicateKey={false}
isList={true}
isInputField={true}
testId="http-headers"
enableGlobalVariables={true}
/>
</div>
<div className="flex flex-col gap-2">

View File

@ -532,12 +532,20 @@ test(
// Add first header
await page.getByTestId("http-headers-key-0").fill(testHeaderKey1);
await page.getByTestId("http-headers-value-0").fill(testHeaderValue1);
// The value field uses InputComponent with global variables, so we need to find it by placeholder
await page
.getByPlaceholder("Type a value...")
.first()
.fill(testHeaderValue1);
// Add second header
await page.getByTestId("http-headers-plus-btn-0").click();
await page.getByTestId("http-headers-key-1").fill(testHeaderKey2);
await page.getByTestId("http-headers-value-1").fill(testHeaderValue2);
// Use nth(1) to get the second value field
await page
.getByPlaceholder("Type a value...")
.nth(1)
.fill(testHeaderValue2);
// Add first environment variable
await page.getByTestId("http-env-key-0").fill(testEnvKey1);
@ -591,15 +599,16 @@ test(
expect(await page.getByTestId("http-headers-key-0").inputValue()).toBe(
testHeaderKey1,
);
expect(await page.getByTestId("http-headers-value-0").inputValue()).toBe(
testHeaderValue1,
);
// Header values use InputComponent with global variables, so we verify by placeholder
expect(
await page.getByPlaceholder("Type a value...").first().inputValue(),
).toBe(testHeaderValue1);
expect(await page.getByTestId("http-headers-key-1").inputValue()).toBe(
testHeaderKey2,
);
expect(await page.getByTestId("http-headers-value-1").inputValue()).toBe(
testHeaderValue2,
);
expect(
await page.getByPlaceholder("Type a value...").nth(1).inputValue(),
).toBe(testHeaderValue2);
expect(await page.getByTestId("http-env-key-0").inputValue()).toBe(
testEnvKey1,
);

View File

@ -104,7 +104,9 @@ test(
.fill("testtesttesttesttesttesttesttest");
await page.getByTestId("popover-anchor-apply-to-fields").click();
await page.getByPlaceholder("Fields").waitFor({
const fieldsCount = await page.getByPlaceholder("Fields").count();
await page.getByPlaceholder("Fields").first().waitFor({
state: "visible",
timeout: 30000,
});
@ -113,7 +115,12 @@ test(
expect(fieldSelected).toBe(true);
await page.keyboard.press("Escape");
await page.getByText("Save Variable", { exact: true }).click();
await page
.getByText("Save Variable", { exact: true })
.dispatchEvent("click");
await page.waitForTimeout(500);
await expect(page.getByText(randomName).last()).toBeVisible({
timeout: 10000,
@ -130,7 +137,11 @@ test(
.getByPlaceholder("Enter a name for the variable...")
.fill(randomName2);
await page.getByText("Update Variable", { exact: true }).last().click();
await page
.getByText("Update Variable", { exact: true })
.last()
.dispatchEvent("click");
await page.waitForTimeout(500);
await expect(page.getByText(randomName2).last()).toBeVisible({
timeout: 10000,
@ -149,7 +160,11 @@ test(
.getByPlaceholder("Enter a name for the variable...")
.fill(randomName3);
await page.getByText("Update Variable", { exact: true }).last().click();
await page
.getByText("Update Variable", { exact: true })
.last()
.dispatchEvent("click");
await page.waitForTimeout(500);
await expect(page.getByText(randomName3).last()).toBeVisible({
timeout: 10000,

File diff suppressed because one or more lines are too long

View File

@ -1241,7 +1241,7 @@
},
"MCPTools": {
"versions": {
"0.3.0": "f509359ff41f"
"0.3.0": "a3700ab467a1"
}
},
"Memory": {

View File

@ -405,18 +405,20 @@ def _is_valid_key_value_item(item: Any) -> bool:
return isinstance(item, dict) and "key" in item and "value" in item
def _process_headers(headers: Any) -> dict:
"""Process the headers input into a valid dictionary.
def _process_headers(headers: Any, request_variables: dict[str, str] | None = None) -> dict:
"""Process the headers input into a valid dictionary and resolve global variables.
Args:
headers: The headers to process, can be dict, str, or list
request_variables: Optional dict of global variables to resolve header values
Returns:
Processed and validated dictionary
Processed and validated dictionary with resolved global variable values
"""
if headers is None:
return {}
if isinstance(headers, dict):
return validate_headers(headers)
resolved_headers = _resolve_global_variables_in_headers(headers, request_variables)
return validate_headers(resolved_headers)
if isinstance(headers, list):
processed_headers = {}
try:
@ -428,10 +430,34 @@ def _process_headers(headers: Any) -> dict:
processed_headers[key] = value
except (KeyError, TypeError, ValueError):
return {} # Return empty dictionary instead of None
return validate_headers(processed_headers)
resolved_headers = _resolve_global_variables_in_headers(processed_headers, request_variables)
return validate_headers(resolved_headers)
return {}
def _resolve_global_variables_in_headers(headers: dict, request_variables: dict[str, str] | None) -> dict:
"""Resolve global variable names in header values to their actual values.
Args:
headers: Dictionary of headers where values might be global variable names
request_variables: Dictionary of global variables from request context
Returns:
Dictionary with resolved header values
"""
if not request_variables:
return headers
resolved = {}
for key, value in headers.items():
# If the value matches a global variable name, replace it with the actual value
if isinstance(value, str) and value in request_variables:
resolved[key] = request_variables[value]
else:
resolved[key] = value
return resolved
def _validate_node_installation(command: str) -> str:
"""Validate the npx command."""
if "npx" in command and not shutil.which("node"):
@ -1517,8 +1543,18 @@ async def update_tools(
mcp_stdio_client: MCPStdioClient | None = None,
mcp_streamable_http_client: MCPStreamableHttpClient | None = None,
mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility
request_variables: dict[str, str] | None = None,
) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]:
"""Fetch server config and update available tools."""
"""Fetch server config and update available tools.
Args:
server_name: Name of the MCP server
server_config: Server configuration dictionary
mcp_stdio_client: Optional stdio client instance
mcp_streamable_http_client: Optional streamable HTTP client instance
mcp_sse_client: Optional SSE client instance (backward compatibility)
request_variables: Optional dict of global variables to resolve in headers
"""
if server_config is None:
server_config = {}
if not server_name:
@ -1539,7 +1575,7 @@ async def update_tools(
command = server_config.get("command", "")
url = server_config.get("url", "")
tools = []
headers = _process_headers(server_config.get("headers", {}))
headers = _process_headers(server_config.get("headers", {}), request_variables)
try:
await _validate_connection_params(mode, command, url)

View File

@ -20,7 +20,7 @@ from lfx.io.schema import flatten_schema, schema_to_langflow_inputs
from lfx.log.logger import logger
from lfx.schema.dataframe import DataFrame
from lfx.schema.message import Message
from lfx.services.deps import get_settings_service, get_storage_service, session_scope
from lfx.services.deps import get_storage_service, session_scope
def resolve_mcp_config(
@ -231,6 +231,8 @@ class MCPToolsComponent(ComponentWithCache):
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. "
@ -294,12 +296,33 @@ class MCPToolsComponent(ComponentWithCache):
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")]

View File

@ -70,6 +70,19 @@ class VariableServiceProtocol(Protocol):
"""Set variable value."""
...
@abstractmethod
async def get_all_decrypted_variables(self, user_id: Any, session: Any) -> dict[str, str]:
"""Get all variables for a user with decrypted values.
Args:
user_id: The user ID to get variables for
session: Database session
Returns:
Dictionary mapping variable names to decrypted values
"""
...
class CacheServiceProtocol(Protocol):
"""Protocol for cache service."""

11818
uv.lock generated

File diff suppressed because it is too large Load Diff