Skip to content

Commit ceb0898

Browse files
committed
Merge latest main: combine client request properties with correlation ID tracing and global timeout
2 parents e166835 + bff1be3 commit ceb0898

File tree

6 files changed

+236
-13
lines changed

6 files changed

+236
-13
lines changed

fabric_rti_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
try:
22
from importlib.metadata import version
33

4-
__version__ = version("fabric-rti-mcp")
4+
__version__ = version("microsoft-fabric-rti-mcp")
55
except Exception:
66
__version__ = "0.0.0.dev0"

fabric_rti_mcp/common.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ class GlobalFabricRTIConfig:
2121
@staticmethod
2222
def from_env() -> GlobalFabricRTIConfig:
2323
return GlobalFabricRTIConfig(
24-
fabric_api_base=os.getenv(GlobalFabricRTIEnvVarNames.default_fabric_api_base, DEFAULT_FABRIC_API_BASE)
24+
fabric_api_base=os.getenv(GlobalFabricRTIEnvVarNames.default_fabric_api_base, DEFAULT_FABRIC_API_BASE),
2525
)
2626

2727
@staticmethod
2828
def existing_env_vars() -> list[str]:
2929
"""Return a list of environment variable names that are currently set."""
3030
return [GlobalFabricRTIEnvVarNames.default_fabric_api_base]
31+
32+
33+
# Global configuration instance
34+
config = GlobalFabricRTIConfig.from_env()

fabric_rti_mcp/kusto/kusto_config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class KustoEnvVarNames:
2424
known_services = "KUSTO_KNOWN_SERVICES"
2525
eager_connect = "KUSTO_EAGER_CONNECT"
2626
allow_unknown_services = "KUSTO_ALLOW_UNKNOWN_SERVICES"
27+
timeout = "FABRIC_RTI_KUSTO_TIMEOUT"
2728

2829
@staticmethod
2930
def all() -> List[str]:
@@ -35,6 +36,7 @@ def all() -> List[str]:
3536
KustoEnvVarNames.known_services,
3637
KustoEnvVarNames.eager_connect,
3738
KustoEnvVarNames.allow_unknown_services,
39+
KustoEnvVarNames.timeout,
3840
]
3941

4042

@@ -52,6 +54,8 @@ class KustoConfig:
5254
# Security setting to allow unknown services. If this is set to False,
5355
# only services in known_services will be allowed.
5456
allow_unknown_services: bool = True
57+
# Global timeout for all Kusto operations in seconds
58+
timeout_seconds: Optional[int] = None
5559

5660
@staticmethod
5761
def from_env() -> KustoConfig:
@@ -72,6 +76,16 @@ def from_env() -> KustoConfig:
7276
eager_connect = os.getenv(KustoEnvVarNames.eager_connect, "false").lower() in ("true", "1")
7377
allow_unknown_services = os.getenv(KustoEnvVarNames.allow_unknown_services, "true").lower() in ("true", "1")
7478

79+
# Parse timeout configuration
80+
timeout_seconds = None
81+
timeout_env = os.getenv(KustoEnvVarNames.timeout)
82+
if timeout_env:
83+
try:
84+
timeout_seconds = int(timeout_env)
85+
except ValueError:
86+
# Ignore invalid timeout values
87+
pass
88+
7589
if known_services_string:
7690
try:
7791
known_services_json = json.loads(known_services_string)
@@ -80,7 +94,12 @@ def from_env() -> KustoConfig:
8094
logger.error(f"Failed to parse {KustoEnvVarNames.known_services}: {e}. Skipping known services.")
8195

8296
return KustoConfig(
83-
default_service, open_ai_embedding_endpoint, known_services, eager_connect, allow_unknown_services
97+
default_service,
98+
open_ai_embedding_endpoint,
99+
known_services,
100+
eager_connect,
101+
allow_unknown_services,
102+
timeout_seconds,
84103
)
85104

86105
@staticmethod

fabric_rti_mcp/kusto/kusto_service.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313

1414
from fabric_rti_mcp import __version__ # type: ignore
15+
from fabric_rti_mcp.common import logger
1516
from fabric_rti_mcp.kusto.kusto_config import KustoConfig
1617
from fabric_rti_mcp.kusto.kusto_connection import KustoConnection, sanitize_uri
1718
from fabric_rti_mcp.kusto.kusto_response_formatter import format_results
@@ -100,7 +101,15 @@ def _crp(
100101
if not is_destructive and not ignore_readonly:
101102
crp.set_option("request_readonly", True)
102103

103-
# Apply any additional properties provided by the user
104+
# Set global timeout if configured
105+
if CONFIG.timeout_seconds is not None:
106+
# Convert seconds to timespan format (HH:MM:SS)
107+
hours, remainder = divmod(CONFIG.timeout_seconds, 3600)
108+
minutes, seconds = divmod(remainder, 60)
109+
timeout_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
110+
crp.set_option("servertimeout", timeout_str)
111+
112+
# Apply any additional properties provided by the user (can override global settings)
104113
if additional_properties:
105114
for key, value in additional_properties.items():
106115
crp.set_option(key, value)
@@ -120,18 +129,27 @@ def _execute(
120129
caller_func = caller_frame.f_globals.get(action_name) # type: ignore
121130
is_destructive = hasattr(caller_func, "_is_destructive")
122131

123-
connection = get_kusto_connection(cluster_uri)
124-
client = connection.query_client
132+
# Generate correlation ID for tracing
133+
crp = _crp(action_name, is_destructive, readonly_override, client_request_properties)
134+
correlation_id = crp.client_request_id # type: ignore
125135

126-
# agents can send messy inputs
127-
query = query.strip()
136+
try:
137+
connection = get_kusto_connection(cluster_uri)
138+
client = connection.query_client
128139

129-
database = database or connection.default_database
130-
database = database.strip()
140+
# agents can send messy inputs
141+
query = query.strip()
131142

132-
crp = _crp(action_name, is_destructive, readonly_override, client_request_properties)
133-
result_set = client.execute(database, query, crp)
134-
return format_results(result_set)
143+
database = database or connection.default_database
144+
database = database.strip()
145+
146+
result_set = client.execute(database, query, crp)
147+
return format_results(result_set)
148+
149+
except Exception as e:
150+
error_msg = f"Error executing Kusto operation '{action_name}' (correlation ID: {correlation_id}): {str(e)}"
151+
logger.error(error_msg)
152+
raise RuntimeError(error_msg) from e
135153

136154

137155
# NOTE: This is temporary. The intent is to not use environment variables for persistency.

tests/kusto/test_global_timeout.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Test global timeout configuration for Kusto tools."""
2+
3+
import os
4+
from unittest.mock import Mock, patch
5+
6+
from azure.kusto.data import ClientRequestProperties
7+
8+
from fabric_rti_mcp.kusto.kusto_config import KustoConfig
9+
from fabric_rti_mcp.kusto.kusto_service import kusto_query
10+
11+
12+
def test_config_loads_timeout_from_env() -> None:
13+
"""Test that KustoConfig loads timeout from FABRIC_RTI_KUSTO_TIMEOUT environment variable."""
14+
with patch.dict(os.environ, {"FABRIC_RTI_KUSTO_TIMEOUT": "300"}):
15+
test_config = KustoConfig.from_env()
16+
assert test_config.timeout_seconds == 300
17+
18+
19+
def test_config_handles_invalid_timeout() -> None:
20+
"""Test that KustoConfig handles invalid timeout values gracefully."""
21+
with patch.dict(os.environ, {"FABRIC_RTI_KUSTO_TIMEOUT": "invalid"}):
22+
test_config = KustoConfig.from_env()
23+
assert test_config.timeout_seconds is None
24+
25+
26+
def test_config_no_timeout_env() -> None:
27+
"""Test that KustoConfig handles missing environment variable."""
28+
with patch.dict(os.environ, {}, clear=True):
29+
test_config = KustoConfig.from_env()
30+
assert test_config.timeout_seconds is None
31+
32+
33+
@patch("fabric_rti_mcp.kusto.kusto_service.get_kusto_connection")
34+
@patch("fabric_rti_mcp.kusto.kusto_service.format_results")
35+
def test_global_timeout_applied_to_query(mock_format_results: Mock, mock_get_connection: Mock) -> None:
36+
"""Test that global timeout is applied to Kusto queries."""
37+
# Mock connection
38+
mock_connection = Mock()
39+
mock_connection.default_database = "TestDB"
40+
mock_client = Mock()
41+
mock_connection.query_client = mock_client
42+
mock_get_connection.return_value = mock_connection
43+
44+
# Mock format_results to return expected result
45+
mock_format_results.return_value = [{"test": "result"}]
46+
47+
# Mock the kusto config with timeout
48+
with patch("fabric_rti_mcp.kusto.kusto_service.CONFIG") as mock_config:
49+
mock_config.timeout_seconds = 600
50+
kusto_query("TestQuery", "https://test.kusto.windows.net")
51+
52+
# Verify that execute was called with ClientRequestProperties
53+
mock_client.execute.assert_called_once()
54+
call_args = mock_client.execute.call_args
55+
crp = call_args[0][2] # Third argument should be ClientRequestProperties
56+
57+
assert isinstance(crp, ClientRequestProperties)
58+
# The timeout should be set as server timeout option in HH:MM:SS format
59+
# 600 seconds = 10 minutes = 00:10:00
60+
expected_timeout = "00:10:00"
61+
assert crp._options.get("servertimeout") == expected_timeout
62+
63+
64+
@patch("fabric_rti_mcp.kusto.kusto_service.get_kusto_connection")
65+
@patch("fabric_rti_mcp.kusto.kusto_service.format_results")
66+
def test_no_timeout_when_not_configured(mock_format_results: Mock, mock_get_connection: Mock) -> None:
67+
"""Test that no timeout is set when not configured."""
68+
# Mock connection
69+
mock_connection = Mock()
70+
mock_connection.default_database = "TestDB"
71+
mock_client = Mock()
72+
mock_connection.query_client = mock_client
73+
mock_get_connection.return_value = mock_connection
74+
75+
# Mock format_results to return expected result
76+
mock_format_results.return_value = [{"test": "result"}]
77+
78+
# Mock the kusto config without timeout
79+
with patch("fabric_rti_mcp.kusto.kusto_service.CONFIG") as mock_config:
80+
mock_config.timeout_seconds = None
81+
kusto_query("TestQuery", "https://test.kusto.windows.net")
82+
83+
# Verify that execute was called with ClientRequestProperties
84+
mock_client.execute.assert_called_once()
85+
call_args = mock_client.execute.call_args
86+
crp = call_args[0][2] # Third argument should be ClientRequestProperties
87+
88+
assert isinstance(crp, ClientRequestProperties)
89+
# No timeout should be set
90+
assert "servertimeout" not in crp._options

tests/kusto/test_kusto_service.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import json
12
from unittest.mock import MagicMock, Mock, patch
23

4+
import pytest
35
from azure.kusto.data import ClientRequestProperties
46
from azure.kusto.data.response import KustoResponseDataSet
57

@@ -21,6 +23,7 @@ def test_execute_basic_query(
2123
mock_connection = MagicMock()
2224
mock_connection.query_client = mock_client
2325
mock_connection.default_database = "default_db"
26+
mock_connection.timeout_seconds = None # Add timeout_seconds attribute
2427
mock_get_kusto_connection.return_value = mock_connection
2528

2629
query = " TestTable | take 10 " # Added whitespace to test stripping
@@ -197,3 +200,92 @@ def test_destructive_operation_with_custom_client_request_properties(
197200
assert isinstance(result, list)
198201
assert len(result) == 1
199202
assert result[0]["TestColumn"] == "TestValue"
203+
204+
205+
@patch("fabric_rti_mcp.kusto.kusto_service.get_kusto_connection")
206+
def test_execute_error_includes_correlation_id(
207+
mock_get_kusto_connection: Mock,
208+
sample_cluster_uri: str,
209+
) -> None:
210+
"""Test that errors include correlation ID for easier debugging."""
211+
# Arrange
212+
mock_client = MagicMock()
213+
mock_client.execute.side_effect = Exception("Kusto execution failed")
214+
215+
mock_connection = MagicMock()
216+
mock_connection.query_client = mock_client
217+
mock_connection.default_database = "default_db"
218+
mock_get_kusto_connection.return_value = mock_connection
219+
220+
query = "TestTable | take 10"
221+
database = "test_db"
222+
223+
# Act & Assert
224+
with pytest.raises(RuntimeError) as exc_info:
225+
kusto_query(query, sample_cluster_uri, database=database)
226+
227+
error_message = str(exc_info.value)
228+
229+
# Verify the error message includes correlation ID and operation name
230+
assert "correlation ID:" in error_message
231+
assert "KFRTI_MCP.kusto_query:" in error_message
232+
assert "kusto_query" in error_message
233+
assert "Kusto execution failed" in error_message
234+
235+
236+
@patch("fabric_rti_mcp.kusto.kusto_service.get_kusto_connection")
237+
def test_execute_json_parse_error_includes_correlation_id(
238+
mock_get_kusto_connection: Mock,
239+
sample_cluster_uri: str,
240+
) -> None:
241+
"""Test that JSON parsing errors include correlation ID for easier debugging."""
242+
# Arrange - simulate the kind of error that was happening in the issue
243+
mock_client = MagicMock()
244+
mock_client.execute.side_effect = json.JSONDecodeError("Expecting value", "", 0)
245+
246+
mock_connection = MagicMock()
247+
mock_connection.query_client = mock_client
248+
mock_connection.default_database = "default_db"
249+
mock_get_kusto_connection.return_value = mock_connection
250+
251+
command = ".show version"
252+
253+
# Act & Assert
254+
with pytest.raises(RuntimeError) as exc_info:
255+
kusto_command(command, sample_cluster_uri)
256+
257+
error_message = str(exc_info.value)
258+
259+
# Verify the error message includes correlation ID and operation name
260+
assert "correlation ID:" in error_message
261+
assert "KFRTI_MCP.kusto_command:" in error_message
262+
assert "kusto_command" in error_message
263+
assert "Expecting value" in error_message
264+
265+
266+
@patch("fabric_rti_mcp.kusto.kusto_service.logger")
267+
@patch("fabric_rti_mcp.kusto.kusto_service.get_kusto_connection")
268+
def test_successful_operations_do_not_log_correlation_id(
269+
mock_get_kusto_connection: Mock,
270+
mock_logger: Mock,
271+
sample_cluster_uri: str,
272+
mock_kusto_response: KustoResponseDataSet,
273+
) -> None:
274+
"""Test that successful operations do not log correlation IDs (only errors do)."""
275+
# Arrange
276+
mock_client = MagicMock()
277+
mock_client.execute.return_value = mock_kusto_response
278+
279+
mock_connection = MagicMock()
280+
mock_connection.query_client = mock_client
281+
mock_connection.default_database = "default_db"
282+
mock_get_kusto_connection.return_value = mock_connection
283+
284+
query = "TestTable | take 10"
285+
286+
# Act
287+
kusto_query(query, sample_cluster_uri)
288+
289+
# Assert - verify no info or debug logging occurs for successful operations
290+
assert not mock_logger.info.called
291+
assert not mock_logger.debug.called

0 commit comments

Comments
 (0)