mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 01:22:23 +08:00
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:
committed by
GitHub
parent
f8ada15e2a
commit
60e2ea874d
File diff suppressed because one or more lines are too long
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"] == ""
|
||||
72
src/lfx/tests/unit/mcp/test_tool_execution_timeout.py
Normal file
72
src/lfx/tests/unit/mcp/test_tool_execution_timeout.py
Normal 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
|
||||
Reference in New Issue
Block a user