feat: add MCP timeout configuration support (#12999)

* feat: add per-component MCP tool execution timeout override

- Add mcp_tool_execution_timeout global setting (180s default)
- Add FloatInput in MCP component for per-component timeout override
- Update MCPStdioClient and MCPStreamableHttpClient to accept timeout parameter
- Implement timeout fallback logic: component → global → 180s default
- Add comprehensive unit tests for timeout configuration

This complements the mcp_server_timeout setting (PR #12996) by allowing
per-component customization of tool execution timeouts, supporting
long-running MCP operations (>30s).

Related to customer issue TS021996258 (Verizon).

* fix: preserve client timeout and validate negative values

- Only update client timeout when tool_execution_timeout is not None
- Properly set timeout on mcp_sse_client when used as alias
- Add validation to reject negative timeout values in MCP component
- Add comprehensive tests for timeout preservation, SSE client, and validation

Fixes issues where:
1. Passing None would overwrite existing client timeouts
2. mcp_sse_client wouldn't receive timeout updates
3. Negative timeout values were accepted without validation

* feat: add configurable MCP tool execution timeout with backward compatibility

- Add mcp_tool_execution_timeout setting (default: 180s) for long-running MCP tools
- Implement 3-tier timeout fallback: component > global setting > max(mcp_server_timeout, 180)
- Include timeout in cache keys to prevent stale cached tools from bypassing per-component timeouts
- Add comprehensive test suite (33 tests) covering timeout configuration, backward compatibility, and cache behavior
- Preserve existing deployments: when mcp_tool_execution_timeout is unset, falls back to max(mcp_server_timeout, 180)

Fixes: TS021996258 (Verizon - long-running MCP tool timeouts)

Co-authored-by: autofix.ci <autofix@autofix.ci>

* chore: auto-bake note keys and regenerate backend locales/en.json [skip ci]

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

* fix: align mcp timeout coverage and component validation

* chore: auto-bake note keys and regenerate backend locales/en.json [skip ci]

* fix(mcp): resolve timeout execution bugs and add validation testing

* [autofix.ci] apply automated fixes

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

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* chore: auto-bake note keys and regenerate backend locales/en.json [skip ci]

---------

Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local>
Co-authored-by: autofix.ci <autofix@autofix.ci>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Eric Hare <ericrhare@gmail.com>
This commit is contained in:
Janardan Singh Kavia
2026-05-29 00:01:56 -04:00
committed by GitHub
parent f8ada15e2a
commit 60e2ea874d
9 changed files with 717 additions and 75 deletions

File diff suppressed because one or more lines are too long

View File

@ -4049,44 +4049,6 @@
"components.huggingfacemodel.inputs.typical_p.info.ed0e56c7": "Typical Decoding mass.",
"components.huggingfacemodel.outputs.model_output.display_name.698eabeb": "Language Model",
"components.huggingfacemodel.outputs.text_output.display_name.2544139a": "Model Response",
"components.ibmwatsonxmodel.description.4ae4048c": "Generate text using IBM watsonx.ai foundation models.",
"components.ibmwatsonxmodel.display_name.3cd17f91": "IBM watsonx.ai",
"components.ibmwatsonxmodel.inputs.api_key.display_name.c1368655": "Watsonx API Key",
"components.ibmwatsonxmodel.inputs.api_key.info.f48121a7": "The API Key to use for the model.",
"components.ibmwatsonxmodel.inputs.base_url.display_name.a882cd1d": "watsonx API Endpoint",
"components.ibmwatsonxmodel.inputs.base_url.info.ea1445bd": "The base URL of the API.",
"components.ibmwatsonxmodel.inputs.frequency_penalty.display_name.23155cbf": "Frequency Penalty",
"components.ibmwatsonxmodel.inputs.frequency_penalty.info.21188aa8": "Penalty for frequency of token usage.",
"components.ibmwatsonxmodel.inputs.input_value.display_name.36ecb4f8": "Input",
"components.ibmwatsonxmodel.inputs.logit_bias.display_name.0734e792": "Logit Bias",
"components.ibmwatsonxmodel.inputs.logit_bias.info.b7538c07": "JSON string of token IDs to bias or suppress (e.g., {\"1003\": -100, \"1004\": 100}).",
"components.ibmwatsonxmodel.inputs.logprobs.display_name.9488a8cd": "Log Probabilities",
"components.ibmwatsonxmodel.inputs.logprobs.info.cb94ab04": "Whether to return log probabilities of the output tokens.",
"components.ibmwatsonxmodel.inputs.max_tokens.display_name.c18e6c7f": "Max Tokens",
"components.ibmwatsonxmodel.inputs.max_tokens.info.dc26b19f": "The maximum number of tokens to generate.",
"components.ibmwatsonxmodel.inputs.model_name.display_name.a501a70a": "Model Name",
"components.ibmwatsonxmodel.inputs.presence_penalty.display_name.afe0af5f": "Presence Penalty",
"components.ibmwatsonxmodel.inputs.presence_penalty.info.01c276fd": "Penalty for token presence in prior text.",
"components.ibmwatsonxmodel.inputs.project_id.display_name.d1c47641": "watsonx Project_ID",
"components.ibmwatsonxmodel.inputs.project_id.info.1cf73803": "The project ID associated with the foundation model.",
"components.ibmwatsonxmodel.inputs.seed.display_name.c740e9da": "Random Seed",
"components.ibmwatsonxmodel.inputs.seed.info.9a5b508e": "The random seed for the model.",
"components.ibmwatsonxmodel.inputs.space_id.display_name.f5156dd8": "watsonx Space_ID",
"components.ibmwatsonxmodel.inputs.space_id.info.5695c3d0": "The deployment space ID associated with the foundation model.",
"components.ibmwatsonxmodel.inputs.stop_sequence.display_name.cd87248f": "Stop Sequence",
"components.ibmwatsonxmodel.inputs.stop_sequence.info.60195410": "Sequence where generation should stop.",
"components.ibmwatsonxmodel.inputs.stream.display_name.1eec3071": "Stream",
"components.ibmwatsonxmodel.inputs.stream.info.f835b3ff": "Stream the response from the model. Streaming works only in Chat.",
"components.ibmwatsonxmodel.inputs.system_message.display_name.8e5a3143": "System Message",
"components.ibmwatsonxmodel.inputs.system_message.info.149360fa": "System message to pass to the model.",
"components.ibmwatsonxmodel.inputs.temperature.display_name.b958ce8b": "Temperature",
"components.ibmwatsonxmodel.inputs.temperature.info.3497632c": "Controls randomness, higher values increase diversity.",
"components.ibmwatsonxmodel.inputs.top_logprobs.display_name.22a9dc31": "Top Log Probabilities",
"components.ibmwatsonxmodel.inputs.top_logprobs.info.d06c6c24": "Number of most likely tokens to return at each position.",
"components.ibmwatsonxmodel.inputs.top_p.display_name.c7a8872a": "Top P",
"components.ibmwatsonxmodel.inputs.top_p.info.45956258": "The cumulative probability cutoff for token selection. Lower values mean sampling from a smaller, more top-weighted nucleus.",
"components.ibmwatsonxmodel.outputs.model_output.display_name.698eabeb": "Language Model",
"components.ibmwatsonxmodel.outputs.text_output.display_name.2544139a": "Model Response",
"components.idgenerator.description.050f38b7": "Generates a unique ID.",
"components.idgenerator.display_name.717d5edc": "ID Generator",
"components.idgenerator.inputs.unique_id.display_name.8e37953d": "Value",
@ -4567,6 +4529,8 @@
"components.mcptools.inputs.mcp_server.info.a40e8466": "Select the MCP Server that will be used by this component",
"components.mcptools.inputs.tool.display_name.2e53bdcd": "Tool",
"components.mcptools.inputs.tool.info.8999dc11": "Select the tool to execute",
"components.mcptools.inputs.tool_execution_timeout.display_name.48b62b89": "Tool Execution Timeout (seconds)",
"components.mcptools.inputs.tool_execution_timeout.info.3bff216b": "Maximum time to wait for tool execution before timing out. Supports decimal values for sub-second timeouts (e.g., 0.01 for 10ms). Set to 0 to use the system-configured MCP timeout.",
"components.mcptools.inputs.tool_placeholder.display_name.6d01d1af": "Tool Placeholder",
"components.mcptools.inputs.tool_placeholder.info.91cae561": "Placeholder for the tool",
"components.mcptools.inputs.use_cache.display_name.950b319b": "Use Cached Server",
@ -6539,20 +6503,6 @@
"components.vlmruntranscription.inputs.timeout_seconds.display_name.1f966032": "Timeout (seconds)",
"components.vlmruntranscription.inputs.timeout_seconds.info.1f8d294d": "Maximum time to wait for processing completion",
"components.vlmruntranscription.outputs.result.display_name.6e7d50e8": "Result",
"components.watsonxembeddingscomponent.description.e5f3117c": "Generate embeddings using IBM watsonx.ai models.",
"components.watsonxembeddingscomponent.display_name.0b8a6a96": "IBM watsonx.ai Embeddings",
"components.watsonxembeddingscomponent.inputs.api_key.display_name.c1368655": "Watsonx API Key",
"components.watsonxembeddingscomponent.inputs.api_key.info.f48121a7": "The API Key to use for the model.",
"components.watsonxembeddingscomponent.inputs.input_text.display_name.8a7917fb": "Include the original text in the output",
"components.watsonxembeddingscomponent.inputs.model_name.display_name.a501a70a": "Model Name",
"components.watsonxembeddingscomponent.inputs.project_id.display_name.d1c47641": "watsonx Project_ID",
"components.watsonxembeddingscomponent.inputs.project_id.info.96ceeac2": "The project ID associated with the embedding model.",
"components.watsonxembeddingscomponent.inputs.space_id.display_name.f5156dd8": "watsonx Space_ID",
"components.watsonxembeddingscomponent.inputs.space_id.info.ccea7db1": "The deployment space ID associated with the embedding model.",
"components.watsonxembeddingscomponent.inputs.truncate_input_tokens.display_name.becdfebd": "Truncate Input Tokens",
"components.watsonxembeddingscomponent.inputs.url.display_name.a882cd1d": "watsonx API Endpoint",
"components.watsonxembeddingscomponent.inputs.url.info.ea1445bd": "The base URL of the API.",
"components.watsonxembeddingscomponent.outputs.embeddings.display_name.860ec100": "Embedding Model",
"components.weaviate.description.fb9d3e1f": "Weaviate Vector Store with search capabilities",
"components.weaviate.display_name.e95095e1": "Weaviate",
"components.weaviate.inputs.api_key.display_name.23189d55": "API Key",

View File

@ -0,0 +1,402 @@
"""Tests for MCP timeout configuration feature.
This module tests the configurable timeout parameters added to support
long-running MCP tool executions (>30 seconds).
Related to customer issue TS021996258 (Verizon).
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from lfx.base.mcp.util import MCPStdioClient, MCPStreamableHttpClient, update_tools
class TestMCPTimeoutConfiguration:
"""Test timeout configuration at various levels."""
@pytest.mark.asyncio
async def test_stdio_client_default_timeout(self):
"""Test that MCPStdioClient uses global default timeout (180s)."""
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_setting:
# Simulate: mcp_tool_execution_timeout=180, mcp_server_timeout=20
def get_setting_side_effect(key, default):
if key == "mcp_tool_execution_timeout":
return 180
if key == "mcp_server_timeout":
return 20
return default
mock_get_setting.side_effect = get_setting_side_effect
client = MCPStdioClient()
assert client._tool_execution_timeout == 180
@pytest.mark.asyncio
async def test_stdio_client_custom_timeout(self):
"""Test that MCPStdioClient accepts custom timeout parameter."""
client = MCPStdioClient(tool_execution_timeout=300)
assert client._tool_execution_timeout == 300
@pytest.mark.asyncio
async def test_streamable_http_client_default_timeout(self):
"""Test that MCPStreamableHttpClient uses global default timeout (180s)."""
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_setting:
# Simulate: mcp_tool_execution_timeout=180, mcp_server_timeout=20
def get_setting_side_effect(key, default):
if key == "mcp_tool_execution_timeout":
return 180
if key == "mcp_server_timeout":
return 20
return default
mock_get_setting.side_effect = get_setting_side_effect
client = MCPStreamableHttpClient()
assert client._tool_execution_timeout == 180
@pytest.mark.asyncio
async def test_streamable_http_client_custom_timeout(self):
"""Test that MCPStreamableHttpClient accepts custom timeout parameter."""
client = MCPStreamableHttpClient(tool_execution_timeout=300)
assert client._tool_execution_timeout == 300
@pytest.mark.asyncio
async def test_stdio_run_tool_uses_client_timeout(self):
"""Test that run_tool uses client's configured timeout."""
client = MCPStdioClient(tool_execution_timeout=120)
client._connected = True
client._connection_params = {"command": "test"}
client._session_context = "test_context"
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value={"result": "success"})
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
mock_wait_for.return_value = {"result": "success"}
await client.run_tool("test_tool", {"arg": "value"})
# Verify wait_for was called with client's timeout
assert mock_wait_for.call_count == 1
call_args = mock_wait_for.call_args
assert call_args[1]["timeout"] == 120
@pytest.mark.asyncio
async def test_stdio_run_tool_timeout_override(self):
"""Test that run_tool accepts per-call timeout override."""
client = MCPStdioClient(tool_execution_timeout=120)
client._connected = True
client._connection_params = {"command": "test"}
client._session_context = "test_context"
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value={"result": "success"})
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
mock_wait_for.return_value = {"result": "success"}
# Override with 300 seconds
await client.run_tool("test_tool", {"arg": "value"}, timeout=300)
# Verify wait_for was called with override timeout
assert mock_wait_for.call_count == 1
call_args = mock_wait_for.call_args
assert call_args[1]["timeout"] == 300
@pytest.mark.asyncio
async def test_streamable_http_run_tool_uses_client_timeout(self):
"""Test that StreamableHttpClient run_tool uses client's configured timeout."""
client = MCPStreamableHttpClient(tool_execution_timeout=150)
client._connected = True
client._connection_params = {"url": "http://test"}
client._session_context = "test_context"
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value={"result": "success"})
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
mock_wait_for.return_value = {"result": "success"}
await client.run_tool("test_tool", {"arg": "value"})
# Verify wait_for was called with client's timeout
assert mock_wait_for.call_count == 1
call_args = mock_wait_for.call_args
assert call_args[1]["timeout"] == 150
@pytest.mark.asyncio
async def test_streamable_http_run_tool_timeout_override(self):
"""Test that StreamableHttpClient run_tool accepts per-call timeout override."""
client = MCPStreamableHttpClient(tool_execution_timeout=150)
client._connected = True
client._connection_params = {"url": "http://test"}
client._session_context = "test_context"
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value={"result": "success"})
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
mock_wait_for.return_value = {"result": "success"}
# Override with 400 seconds
await client.run_tool("test_tool", {"arg": "value"}, timeout=400)
# Verify wait_for was called with override timeout
assert mock_wait_for.call_count == 1
call_args = mock_wait_for.call_args
assert call_args[1]["timeout"] == 400
@pytest.mark.asyncio
async def test_update_tools_passes_timeout_to_stdio_client(self):
"""Test that update_tools passes timeout when creating MCPStdioClient."""
server_config = {
"mode": "Stdio",
"command": "test-command",
"args": [],
}
with patch("lfx.base.mcp.util.MCPStdioClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.connect_to_server = AsyncMock(return_value=[])
mock_client._connected = True
mock_client_class.return_value = mock_client
await update_tools(
server_name="test_server",
server_config=server_config,
tool_execution_timeout=250,
)
# Verify MCPStdioClient was created with timeout
mock_client_class.assert_called_once_with(tool_execution_timeout=250)
@pytest.mark.asyncio
async def test_update_tools_passes_timeout_to_http_client(self):
"""Test that update_tools passes timeout when creating MCPStreamableHttpClient."""
server_config = {
"mode": "Streamable_HTTP",
"url": "http://test-server",
}
with patch("lfx.base.mcp.util.MCPStreamableHttpClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.connect_to_server = AsyncMock(return_value=[])
mock_client._connected = True
mock_client_class.return_value = mock_client
await update_tools(
server_name="test_server",
server_config=server_config,
tool_execution_timeout=350,
)
# Verify MCPStreamableHttpClient was created with timeout
mock_client_class.assert_called_once_with(tool_execution_timeout=350)
class TestMCPTimeoutBehavior:
"""Test timeout behavior and error handling."""
@pytest.mark.asyncio
async def test_timeout_error_is_caught_and_retried(self):
"""Test that timeout errors trigger retry logic."""
client = MCPStdioClient(tool_execution_timeout=1)
client._connected = True
client._connection_params = {"command": "test"}
client._session_context = "test_context"
mock_session = AsyncMock()
# First call times out, second succeeds
mock_session.call_tool = AsyncMock(side_effect=[asyncio.TimeoutError(), {"result": "success"}])
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
# First call times out, second succeeds
mock_wait_for.side_effect = [asyncio.TimeoutError(), {"result": "success"}]
result = await client.run_tool("test_tool", {"arg": "value"})
# Verify retry happened
assert mock_wait_for.call_count == 2
assert result == {"result": "success"}
@pytest.mark.asyncio
async def test_zero_timeout_uses_global_default(self):
"""Test that timeout=0 or None falls back to global setting."""
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_setting:
# Simulate: mcp_tool_execution_timeout=180, mcp_server_timeout=20
def get_setting_side_effect(key, default):
if key == "mcp_tool_execution_timeout":
return 180
if key == "mcp_server_timeout":
return 20
return default
mock_get_setting.side_effect = get_setting_side_effect
# Test with None - should fall back to global setting
client1 = MCPStdioClient(tool_execution_timeout=None)
assert client1._tool_execution_timeout == 180
# Test with 0 - should fall back to global setting since 0 is an invalid explicit timeout
client2 = MCPStdioClient(tool_execution_timeout=0)
assert client2._tool_execution_timeout == 180
@pytest.mark.asyncio
async def test_multiple_clients_independent_timeouts(self):
"""Test that multiple client instances maintain independent timeout values."""
# Create multiple clients with different timeout values
client1 = MCPStdioClient(tool_execution_timeout=100)
client2 = MCPStdioClient(tool_execution_timeout=200)
client3 = MCPStreamableHttpClient(tool_execution_timeout=300)
client4 = MCPStreamableHttpClient(tool_execution_timeout=400)
# Verify each client maintains its own timeout value
assert client1._tool_execution_timeout == 100
assert client2._tool_execution_timeout == 200
assert client3._tool_execution_timeout == 300
assert client4._tool_execution_timeout == 400
# Verify changing one doesn't affect others
client1._tool_execution_timeout = 150
assert client1._tool_execution_timeout == 150
assert client2._tool_execution_timeout == 200 # Unchanged
assert client3._tool_execution_timeout == 300 # Unchanged
assert client4._tool_execution_timeout == 400 # Unchanged
@pytest.mark.asyncio
async def test_multiple_tool_calls_independent_timeout_overrides(self):
"""Test that multiple tool calls with different timeout overrides remain independent."""
client = MCPStdioClient(tool_execution_timeout=120)
client._connected = True
client._connection_params = {"command": "test"}
client._session_context = "test_context"
mock_session = AsyncMock()
mock_session.call_tool = AsyncMock(return_value={"result": "success"})
with (
patch.object(client, "_get_or_create_session", return_value=mock_session),
patch("asyncio.wait_for") as mock_wait_for,
):
mock_wait_for.return_value = {"result": "success"}
# Call multiple tools with different timeout overrides
await client.run_tool("tool1", {"arg": "value1"}, timeout=100)
await client.run_tool("tool2", {"arg": "value2"}, timeout=200)
await client.run_tool("tool3", {"arg": "value3"}, timeout=300)
await client.run_tool("tool4", {"arg": "value4"}) # Uses client default (120)
# Verify each call used its own timeout
assert mock_wait_for.call_count == 4
# Check each call's timeout parameter
call_timeouts = [call[1]["timeout"] for call in mock_wait_for.call_args_list]
assert call_timeouts == [100, 200, 300, 120]
@pytest.mark.asyncio
async def test_none_timeout_preserves_existing_client_timeout(self):
"""Test that passing None as timeout preserves existing client's timeout."""
from lfx.base.mcp.util import update_tools
# Create a client with a specific timeout
initial_client = MCPStdioClient(tool_execution_timeout=250)
server_config = {
"mode": "Stdio",
"command": "test-command",
"args": [],
}
with patch.object(initial_client, "connect_to_server", new_callable=AsyncMock) as mock_connect:
mock_connect.return_value = []
initial_client._connected = True
# Call update_tools with None timeout - should preserve existing 250s
await update_tools(
server_name="test_server",
server_config=server_config,
tool_execution_timeout=None,
mcp_stdio_client=initial_client,
)
# Verify the client's timeout was NOT overwritten
assert initial_client._tool_execution_timeout == 250
@pytest.mark.asyncio
async def test_mcp_sse_client_receives_timeout(self):
"""Test that mcp_sse_client (backward compatibility alias) receives timeout."""
from lfx.base.mcp.util import update_tools
# Create an SSE client (which is actually a StreamableHttpClient)
sse_client = MCPStreamableHttpClient(tool_execution_timeout=100)
server_config = {
"mode": "Streamable_HTTP",
"url": "http://test-server",
}
with patch.object(sse_client, "connect_to_server", new_callable=AsyncMock) as mock_connect:
mock_connect.return_value = []
sse_client._connected = True
# Call update_tools with mcp_sse_client and a new timeout
await update_tools(
server_name="test_server",
server_config=server_config,
tool_execution_timeout=350,
mcp_sse_client=sse_client,
)
# Verify the SSE client received the new timeout
assert sse_client._tool_execution_timeout == 350
class TestMCPTimeoutSettingsValidation:
"""Test MCP timeout validation that belongs with backend settings coverage."""
@pytest.mark.asyncio
async def test_global_setting_validation_rejects_zero(self):
"""Test that global mcp_tool_execution_timeout setting rejects zero values."""
from lfx.services.settings.base import Settings
# Test the validator directly
with pytest.raises(ValueError, match="mcp_tool_execution_timeout must be greater than 0"):
Settings.validate_mcp_tool_execution_timeout(0.0)
@pytest.mark.asyncio
async def test_global_setting_validation_rejects_negative(self):
"""Test that global mcp_tool_execution_timeout setting rejects negative values."""
from lfx.services.settings.base import Settings
# Test the validator directly
with pytest.raises(ValueError, match="mcp_tool_execution_timeout must be greater than 0"):
Settings.validate_mcp_tool_execution_timeout(-100.0)
@pytest.mark.asyncio
async def test_global_setting_accepts_positive_float(self):
"""Test that global mcp_tool_execution_timeout setting accepts positive float values."""
from lfx.services.settings.base import Settings
# Test that the validator accepts positive values
# Note: We test the validator logic, not the full Settings initialization
validated_value = Settings.validate_mcp_tool_execution_timeout(180.0)
assert validated_value == 180.0
validated_value = Settings.validate_mcp_tool_execution_timeout(250.5)
assert validated_value == 250.5
validated_value = Settings.validate_mcp_tool_execution_timeout(0.5)
assert validated_value == 0.5

View File

@ -55,6 +55,28 @@ def _get_mcp_setting(key: str, default: Any = None) -> Any:
return _mcp_settings_cache[key]
def _resolve_mcp_tool_execution_timeout(tool_execution_timeout: float | None) -> float:
"""Resolve MCP tool execution timeout from explicit input or MCP settings.
Priority for picking the timeout:
1. `tool_execution_timeout`: Custom UI override directly on the component (Highest).
2. Global Settings: The maximum value between `mcp_tool_execution_timeout`
and `mcp_server_timeout` from global settings.
3. Fallback: 180.0 seconds if no custom or global settings exist (Lowest).
Negative values are treated as unset (use system default) because
``asyncio.wait_for`` immediately raises ``TimeoutError`` for any timeout < 0.
"""
if tool_execution_timeout is not None and float(tool_execution_timeout) > 0:
return float(tool_execution_timeout)
configured = _get_mcp_setting("mcp_tool_execution_timeout", None)
mcp_server_timeout = _get_mcp_setting("mcp_server_timeout", None)
configured_timeouts = [float(value) for value in (configured, mcp_server_timeout) if value is not None]
return max(configured_timeouts) if configured_timeouts else 180.0
def get_max_sessions_per_server() -> int:
"""Get maximum number of sessions per server to prevent resource exhaustion."""
return _get_mcp_setting("mcp_max_sessions_per_server")
@ -1548,12 +1570,13 @@ class MCPSessionManager:
class MCPStdioClient:
def __init__(self, component_cache=None):
def __init__(self, component_cache=None, tool_execution_timeout: float | None = None):
self.session: ClientSession | None = None
self._connection_params = None
self._connected = False
self._session_context: str | None = None
self._component_cache = component_cache
self._tool_execution_timeout = _resolve_mcp_tool_execution_timeout(tool_execution_timeout)
async def _connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
"""Connect to MCP server using stdio transport (SDK style).
@ -1647,12 +1670,13 @@ class MCPStdioClient:
session_manager = self._get_session_manager()
return await session_manager.get_session(self._session_context, self._connection_params, "stdio")
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
async def run_tool(self, tool_name: str, arguments: dict[str, Any], timeout: float | None = None) -> Any: # noqa: ASYNC109
"""Run a tool with the given arguments using context-specific session.
Args:
tool_name: Name of the tool to run
arguments: Dictionary of arguments to pass to the tool
timeout: Optional timeout in seconds. If not provided, uses the client's configured timeout.
Returns:
The result of the tool execution
@ -1672,9 +1696,9 @@ class MCPStdioClient:
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_{param_hash}"
# Tool-call timeout: env LANGFLOW_MCP_SERVER_TIMEOUT (via settings), with a 180s
# floor so default deployments aren't shorter than the previous hardcoded 30s.
timeout = max(get_settings_service().settings.mcp_server_timeout, 180.0)
# Use provided timeout or fall back to client's configured timeout
effective_timeout = timeout if timeout is not None else self._tool_execution_timeout
max_retries = 2
last_error_type = None
@ -1686,7 +1710,7 @@ class MCPStdioClient:
result = await asyncio.wait_for(
session.call_tool(tool_name, arguments=arguments),
timeout=timeout,
timeout=effective_timeout,
)
except Exception as e:
current_error_type = type(e).__name__
@ -1780,12 +1804,13 @@ class MCPStdioClient:
class MCPStreamableHttpClient:
def __init__(self, component_cache=None):
def __init__(self, component_cache=None, tool_execution_timeout: float | None = None):
self.session: ClientSession | None = None
self._connection_params = None
self._connected = False
self._session_context: str | None = None
self._component_cache = component_cache
self._tool_execution_timeout = _resolve_mcp_tool_execution_timeout(tool_execution_timeout)
def _get_session_manager(self) -> MCPSessionManager:
"""Get or create session manager from component cache."""
@ -1930,12 +1955,13 @@ class MCPStreamableHttpClient:
# DELETE is advisory—log and continue
logger.debug(f"Unable to send session DELETE to '{url}': {e}")
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
async def run_tool(self, tool_name: str, arguments: dict[str, Any], timeout: float | None = None) -> Any: # noqa: ASYNC109
"""Run a tool with the given arguments using context-specific session.
Args:
tool_name: Name of the tool to run
arguments: Dictionary of arguments to pass to the tool
timeout: Optional timeout in seconds. If not provided, uses the client's configured timeout.
Returns:
The result of the tool execution
@ -1955,9 +1981,9 @@ class MCPStreamableHttpClient:
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_http_{param_hash}"
# Tool-call timeout: env LANGFLOW_MCP_SERVER_TIMEOUT (via settings), with a 180s
# floor so default deployments aren't shorter than the previous hardcoded 30s.
timeout = max(get_settings_service().settings.mcp_server_timeout, 180.0)
# Use provided timeout or fall back to client's configured timeout
effective_timeout = timeout if timeout is not None else self._tool_execution_timeout
max_retries = 2
last_error_type = None
@ -1969,7 +1995,7 @@ class MCPStreamableHttpClient:
result = await asyncio.wait_for(
session.call_tool(tool_name, arguments=arguments),
timeout=timeout,
timeout=effective_timeout,
)
except Exception as e:
current_error_type = type(e).__name__
@ -2065,6 +2091,7 @@ async def update_tools(
mcp_streamable_http_client: MCPStreamableHttpClient | None = None,
mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility
request_variables: dict[str, str] | None = None,
tool_execution_timeout: float | None = None,
) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]:
"""Fetch server config and update available tools.
@ -2075,17 +2102,35 @@ async def update_tools(
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
tool_execution_timeout: Optional timeout in seconds for tool execution (int or float)
"""
if server_config is None:
server_config = {}
if not server_name:
return "", [], {}
if mcp_stdio_client is None:
mcp_stdio_client = MCPStdioClient()
mcp_stdio_client = MCPStdioClient(tool_execution_timeout=tool_execution_timeout)
# Update timeout on existing client only if a new timeout is provided.
# Route through _resolve_mcp_tool_execution_timeout so that negative values
# (entered before UI validation fires) never reach asyncio.wait_for.
elif tool_execution_timeout is not None:
mcp_stdio_client._tool_execution_timeout = _resolve_mcp_tool_execution_timeout(tool_execution_timeout)
# Backward compatibility: accept mcp_sse_client parameter
if mcp_streamable_http_client is None:
mcp_streamable_http_client = mcp_sse_client if mcp_sse_client is not None else MCPStreamableHttpClient()
if mcp_sse_client is not None:
mcp_streamable_http_client = mcp_sse_client
# Set timeout on the aliased client if provided
if tool_execution_timeout is not None:
mcp_streamable_http_client._tool_execution_timeout = _resolve_mcp_tool_execution_timeout(
tool_execution_timeout
)
else:
mcp_streamable_http_client = MCPStreamableHttpClient(tool_execution_timeout=tool_execution_timeout)
# Update timeout on existing client only if a new timeout is provided
elif tool_execution_timeout is not None:
mcp_streamable_http_client._tool_execution_timeout = _resolve_mcp_tool_execution_timeout(tool_execution_timeout)
# Fetch server config from backend
# Determine mode from config, defaulting to Streamable_HTTP if URL present

View File

@ -20,7 +20,7 @@ from lfx.base.mcp.util import (
from lfx.base.tools.constants import TOOL_OUTPUT_DISPLAY_NAME, TOOL_OUTPUT_NAME
from lfx.custom.custom_component.component_with_cache import ComponentWithCache
from lfx.inputs.inputs import InputTypes # noqa: TC001
from lfx.io import BoolInput, DictInput, DropdownInput, McpInput, MessageTextInput, Output
from lfx.io import BoolInput, DictInput, DropdownInput, FloatInput, McpInput, MessageTextInput, Output
from lfx.io.schema import schema_to_langflow_inputs
from lfx.log.logger import logger
from lfx.schema.dataframe import DataFrame
@ -99,7 +99,8 @@ class MCPToolsComponent(ComponentWithCache):
# Initialize cache keys to avoid CacheMiss when accessing them
self._ensure_cache_structure()
# Initialize clients with access to the component cache
# Initialize clients with access to the component cache.
# Per-component timeout is normalized and applied immediately before MCP calls.
self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)
self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(
component_cache=self._shared_component_cache
@ -138,14 +139,48 @@ class MCPToolsComponent(ComponentWithCache):
return {str(k): str(v) for k, v in component_headers.items()}
return {}
def _normalize_tool_execution_timeout(self) -> float | None:
"""Normalize the timeout input and reject negative values with a field-specific error."""
timeout_value = getattr(self, "tool_execution_timeout", 0.0)
if timeout_value in (None, ""):
return None
try:
val = float(timeout_value)
except (ValueError, TypeError):
return None
if val < 0:
msg = "Tool Execution Timeout must be greater than or equal to 0."
raise ValueError(msg)
return val if val else None
def _mcp_servers_cache_key(self, server_name: str) -> str:
"""Cache key for shared servers map; includes headers so auth/tweak changes get distinct entries."""
"""Cache key for shared servers map.
Includes headers and timeout so auth/tweak/timeout changes get distinct entries.
"""
if not server_name:
return ""
raw_timeout = getattr(self, "tool_execution_timeout", 0.0) or 0.0
normalized_timeout = max(0.0, float(raw_timeout))
hdrs = self._normalized_headers_for_cache()
if not hdrs:
# Build cache key components
cache_data = {
"headers": hdrs,
"timeout": normalized_timeout,
}
# If no headers and default timeout, just use server name
if not hdrs and normalized_timeout == 0.0:
return server_name
payload = json.dumps(hdrs, sort_keys=True)
payload = json.dumps(cache_data, sort_keys=True)
digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
return f"{server_name}:{digest}"
@ -184,6 +219,7 @@ class MCPToolsComponent(ComponentWithCache):
"use_cache",
"verify_ssl",
"headers",
"tool_execution_timeout",
]
display_name = "MCP Tools"
@ -230,6 +266,19 @@ class MCPToolsComponent(ComponentWithCache):
advanced=True,
is_list=True,
),
FloatInput(
name="tool_execution_timeout",
display_name="Tool Execution Timeout (seconds)",
info=(
"Maximum time to wait for tool execution before timing out. "
"Supports decimal values for sub-second timeouts (e.g., 0.01 for 10ms). "
"Set to 0 to use the system-configured MCP timeout."
),
value=0.0,
range_spec={"min": 0.0, "max": 3600.0, "step": 0.01},
real_time_refresh=True,
advanced=True,
),
DropdownInput(
name="tool",
display_name="Tool",
@ -460,12 +509,15 @@ class MCPToolsComponent(ComponentWithCache):
else "list-or-empty",
)
timeout = self._normalize_tool_execution_timeout()
_, 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,
tool_execution_timeout=timeout,
)
self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")]
@ -748,6 +800,18 @@ class MCPToolsComponent(ComponentWithCache):
await logger.aexception(msg)
build_config["tool"]["options"] = []
build_config["tool"]["placeholder"] = msg
elif field_name == "tool_execution_timeout":
try:
val = float(field_value) if field_value not in (None, "") else 0.0
except (ValueError, TypeError):
val = 0.0
if val < 0:
build_config["tool_execution_timeout"]["placeholder"] = (
"⚠ Value must be ≥ 0. Negative timeouts cause immediate failures."
)
build_config["tool_execution_timeout"]["value"] = 0.0
else:
build_config["tool_execution_timeout"]["placeholder"] = ""
elif field_name == "tools_metadata":
self._not_load_actions = False

View File

@ -126,8 +126,23 @@ class Settings(BaseSettings):
the browser's window.location.origin."""
mcp_server_timeout: int = 20
"""The number of seconds to wait before giving up on a lock to released or establishing a connection to the
database."""
"""The number of seconds to wait before giving up on establishing a connection to the MCP server."""
mcp_tool_execution_timeout: float = 180.0
"""Maximum seconds to wait for MCP tool execution before timing out.
Default is 180 seconds (3 minutes) to support long-running operations.
Supports decimal values for sub-second timeouts (e.g., 0.5 for 500ms).
Individual components can override this with their own timeout setting.
Must be a positive number greater than 0."""
@field_validator("mcp_tool_execution_timeout")
@classmethod
def validate_mcp_tool_execution_timeout(cls, v: float) -> float:
"""Validate that mcp_tool_execution_timeout is positive."""
if v <= 0:
msg = "mcp_tool_execution_timeout must be greater than 0"
raise ValueError(msg)
return v
# ---------------------------------------------------------------------
# MCP Session-manager tuning

View File

@ -322,6 +322,21 @@ def test_schema_to_langflow_inputs_preserves_optional_defaults_and_nullable_obje
assert inputs["proxy_country"].value == "us"
def test_float_input_allows_range_spec_minimum_for_non_negative_values():
input_field = FloatInput.model_validate(
{
"name": "tool_execution_timeout",
"value": 0.0,
"range_spec": {"min": 0.0, "max": 3600.0, "step": 0.01},
}
)
assert input_field.range_spec is not None
assert input_field.range_spec.min == 0.0
assert input_field.range_spec.max == 3600.0
assert input_field.range_spec.step == 0.01
def test_schema_to_langflow_inputs_invalid_type():
# Define a schema with an unsupported type
class CustomType:

View File

@ -0,0 +1,58 @@
import pytest
from lfx.components.models_and_agents.mcp_component import MCPToolsComponent
def test_normalize_tool_execution_timeout_returns_none_for_zero():
component = MCPToolsComponent()
component.tool_execution_timeout = 0.0
assert component._normalize_tool_execution_timeout() is None
def test_normalize_tool_execution_timeout_returns_float_for_positive_value():
component = MCPToolsComponent()
component.tool_execution_timeout = 12
assert component._normalize_tool_execution_timeout() == 12.0
def test_normalize_tool_execution_timeout_rejects_negative_value_with_field_specific_message():
component = MCPToolsComponent()
component.tool_execution_timeout = -1
with pytest.raises(ValueError, match=r"Tool Execution Timeout must be greater than or equal to 0\."):
component._normalize_tool_execution_timeout()
def test_mcp_servers_cache_key_clamps_negative_timeout_to_zero():
component = MCPToolsComponent()
component.tool_execution_timeout = -1
cache_key = component._mcp_servers_cache_key("demo-server")
assert "demo-server" in cache_key
@pytest.mark.asyncio
async def test_update_build_config_resets_negative_timeout():
component = MCPToolsComponent()
build_config = {"tool_execution_timeout": {"value": -1, "placeholder": ""}}
result = await component.update_build_config(build_config, "-1", "tool_execution_timeout")
assert result["tool_execution_timeout"]["value"] == 0.0
assert "Negative timeouts cause immediate failures" in result["tool_execution_timeout"]["placeholder"]
@pytest.mark.asyncio
async def test_update_build_config_handles_empty_timeout_safely():
component = MCPToolsComponent()
build_config = {"tool_execution_timeout": {"value": "", "placeholder": ""}}
# Should not crash and should clear any placeholder
result = await component.update_build_config(build_config, "", "tool_execution_timeout")
assert result["tool_execution_timeout"]["placeholder"] == ""
assert result["tool_execution_timeout"]["value"] == ""

View File

@ -0,0 +1,72 @@
from unittest.mock import patch
from lfx.base.mcp.util import (
MCPStdioClient,
MCPStreamableHttpClient,
_resolve_mcp_tool_execution_timeout,
)
def test_resolve_mcp_tool_execution_timeout_uses_explicit_value():
assert _resolve_mcp_tool_execution_timeout(42) == 42.0
def test_resolve_mcp_tool_execution_timeout_uses_max_of_global_settings():
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_mcp_setting:
mock_get_mcp_setting.side_effect = lambda key, default=None: {
"mcp_tool_execution_timeout": 120.0,
"mcp_server_timeout": 240,
}.get(key, default)
assert _resolve_mcp_tool_execution_timeout(None) == 240.0
def test_resolve_mcp_tool_execution_timeout_uses_tool_timeout_when_larger():
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_mcp_setting:
mock_get_mcp_setting.side_effect = lambda key, default=None: {
"mcp_tool_execution_timeout": 300.0,
"mcp_server_timeout": 20,
}.get(key, default)
assert _resolve_mcp_tool_execution_timeout(None) == 300.0
def test_resolve_mcp_tool_execution_timeout_uses_server_timeout_when_tool_timeout_is_missing():
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_mcp_setting:
mock_get_mcp_setting.side_effect = lambda key, default=None: {
"mcp_tool_execution_timeout": None,
"mcp_server_timeout": 240,
}.get(key, default)
assert _resolve_mcp_tool_execution_timeout(None) == 240.0
def test_resolve_mcp_tool_execution_timeout_uses_server_timeout_when_tool_timeout_is_not_set():
with patch("lfx.base.mcp.util._get_mcp_setting") as mock_get_mcp_setting:
mock_get_mcp_setting.side_effect = lambda key, default=None: {
"mcp_server_timeout": 210,
}.get(key, default)
assert _resolve_mcp_tool_execution_timeout(None) == 210.0
def test_resolve_mcp_tool_execution_timeout_falls_back_to_180_when_no_settings_exist():
with patch("lfx.base.mcp.util._get_mcp_setting", return_value=None):
assert _resolve_mcp_tool_execution_timeout(None) == 180.0
def test_mcp_stdio_client_uses_resolved_timeout():
with patch("lfx.base.mcp.util._resolve_mcp_tool_execution_timeout", return_value=240.0):
client = MCPStdioClient(tool_execution_timeout=None)
assert client._tool_execution_timeout == 240.0
def test_mcp_streamable_http_client_uses_resolved_timeout():
with patch("lfx.base.mcp.util._resolve_mcp_tool_execution_timeout", return_value=240.0):
client = MCPStreamableHttpClient(tool_execution_timeout=None)
assert client._tool_execution_timeout == 240.0
# Made with Bob