mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 23:13:58 +08:00
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:
@ -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"
|
||||
}
|
||||
|
||||
@ -113,5 +113,3 @@ def main() -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
# Made with Bob
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,3 +1 @@
|
||||
"""Tests package for langflow."""
|
||||
|
||||
# Made with Bob
|
||||
|
||||
@ -1,3 +1 @@
|
||||
"""Database tests package."""
|
||||
|
||||
# Made with Bob
|
||||
|
||||
@ -1,3 +1 @@
|
||||
"""Database models tests package."""
|
||||
|
||||
# Made with Bob
|
||||
|
||||
@ -1,3 +1 @@
|
||||
"""Transaction models tests package."""
|
||||
|
||||
# Made with Bob
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
204
src/backend/tests/unit/services/auth/test_decrypt_api_key.py
Normal file
204
src/backend/tests/unit/services/auth/test_decrypt_api_key.py
Normal 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)
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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
@ -1241,7 +1241,7 @@
|
||||
},
|
||||
"MCPTools": {
|
||||
"versions": {
|
||||
"0.3.0": "f509359ff41f"
|
||||
"0.3.0": "a3700ab467a1"
|
||||
}
|
||||
},
|
||||
"Memory": {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user