mcp-hydrolix 0.1.4__py3-none-any.whl → 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_hydrolix/mcp_env.py CHANGED
@@ -4,10 +4,12 @@ This module handles all environment variable configuration with sensible default
4
4
  and type conversion.
5
5
  """
6
6
 
7
- from dataclasses import dataclass
8
7
  import os
9
- from typing import Optional
8
+ from dataclasses import dataclass
10
9
  from enum import Enum
10
+ from typing import Optional
11
+
12
+ from .auth.credentials import HydrolixCredential, ServiceAccountToken, UsernamePassword
11
13
 
12
14
 
13
15
  class TransportType(str, Enum):
@@ -46,15 +48,49 @@ class HydrolixConfig:
46
48
  HYDROLIX_MCP_SERVER_TRANSPORT: MCP server transport method - "stdio", "http", or "sse" (default: stdio)
47
49
  HYDROLIX_MCP_BIND_HOST: Host to bind the MCP server to when using HTTP or SSE transport (default: 127.0.0.1)
48
50
  HYDROLIX_MCP_BIND_PORT: Port to bind the MCP server to when using HTTP or SSE transport (default: 8000)
51
+ HYDROLIX_QUERIES_POOL_SIZE 100
52
+ HYDROLIX_MCP_REQUEST_TIMEOUT 120
53
+ HYDROLIX_MCP_WORKERS 3
54
+ HYDROLIX_MCP_WORKER_CONNECTIONS 200
55
+ HYDROLIX_MCP_MAX_REQUESTS 10000
56
+ HYDROLIX_MCP_MAX_REQUESTS_JITTER 1000
57
+ HYDROLIX_MCP_MAX_KEEPALIVE 10
49
58
  """
50
59
 
51
- def __init__(self):
60
+ def __init__(self) -> None:
52
61
  """Initialize the configuration from environment variables."""
53
62
  self._validate_required_vars()
63
+ # Credential to use for clickhouse connections when no per-request credential is provided
64
+ self._default_credential: Optional[HydrolixCredential] = None
65
+
66
+ # Set the default credential to the service account from the environment, if available
67
+ if (global_service_account := os.environ.get("HYDROLIX_TOKEN")) is not None:
68
+ self._default_credential = ServiceAccountToken(
69
+ global_service_account, f"https://{self.host}/config"
70
+ )
71
+ elif (global_username := os.environ.get("HYDROLIX_USER")) is not None and (
72
+ global_password := os.environ.get("HYDROLIX_PASSWORD")
73
+ ) is not None:
74
+ # No global service account available. Set the default credential to the username/password
75
+ # from the environment, if available
76
+ self._default_credential = UsernamePassword(global_username, global_password)
77
+
78
+ def creds_with(self, request_credential: Optional[HydrolixCredential]) -> HydrolixCredential:
79
+ if request_credential is not None:
80
+ return request_credential
81
+ elif self._default_credential is not None:
82
+ return self._default_credential
83
+ else:
84
+ raise ValueError(
85
+ "No credentials available for Hydrolix connection. "
86
+ "Please provide credentials either through HYDROLIX_TOKEN or "
87
+ "HYDROLIX_USER/HYDROLIX_PASSWORD environment variables, "
88
+ "or pass credentials explicitly via the creds parameter."
89
+ )
54
90
 
55
91
  @property
56
92
  def host(self) -> str:
57
- """Get the Hydrolix host."""
93
+ """Get the Hydrolix host. Called during __init__"""
58
94
  return os.environ["HYDROLIX_HOST"]
59
95
 
60
96
  @property
@@ -68,40 +104,6 @@ class HydrolixConfig:
68
104
  return int(os.environ["HYDROLIX_PORT"])
69
105
  return 8088
70
106
 
71
- @property
72
- def service_account(self) -> bool:
73
- """Determine if service account is enabled
74
-
75
- Defaults to false.
76
- Can be overridden if HYDROLIX_TOKEN environment variable.
77
- """
78
- return "HYDROLIX_TOKEN" in os.environ
79
-
80
- @property
81
- def service_account_token(self) -> str:
82
- """Get the service account token
83
-
84
- Defaults to None.
85
- Can be overridden if HYDROLIX_TOKEN environment variable.
86
- """
87
- if "HYDROLIX_TOKEN" in os.environ:
88
- return os.environ["HYDROLIX_TOKEN"]
89
- return None
90
-
91
- @property
92
- def username(self) -> str:
93
- """Get the Hydrolix username."""
94
- if "HYDROLIX_USER" in os.environ:
95
- return os.environ["HYDROLIX_USER"]
96
- return None
97
-
98
- @property
99
- def password(self) -> str:
100
- """Get the Hydrolix password."""
101
- if "HYDROLIX_PASSWORD" in os.environ:
102
- return os.environ["HYDROLIX_PASSWORD"]
103
- return None
104
-
105
107
  @property
106
108
  def database(self) -> Optional[str]:
107
109
  """Get the default database name if set."""
@@ -115,6 +117,14 @@ class HydrolixConfig:
115
117
  """
116
118
  return os.getenv("HYDROLIX_VERIFY", "true").lower() == "true"
117
119
 
120
+ @property
121
+ def secure(self) -> bool:
122
+ """Get whether use secured connection.
123
+
124
+ Default: True
125
+ """
126
+ return os.getenv("HYDROLIX_SECURE", "true").lower() == "true"
127
+
118
128
  @property
119
129
  def connect_timeout(self) -> int:
120
130
  """Get the connection timeout in seconds.
@@ -132,7 +142,23 @@ class HydrolixConfig:
132
142
  return int(os.getenv("HYDROLIX_SEND_RECEIVE_TIMEOUT", "300"))
133
143
 
134
144
  @property
135
- def proxy_path(self) -> str:
145
+ def query_pool_size(self) -> int:
146
+ """Get the send/receive timeout in seconds.
147
+
148
+ Default: 300 (Hydrolix default)
149
+ """
150
+ return int(os.getenv("HYDROLIX_QUERIES_POOL_SIZE", 100))
151
+
152
+ @property
153
+ def query_timeout_sec(self) -> int:
154
+ """Get the send/receive timeout in seconds.
155
+
156
+ Default: 300 (Hydrolix default)
157
+ """
158
+ return int(os.getenv("HYDROLIX_QUERY_TIMEOUT_SECS", 30))
159
+
160
+ @property
161
+ def proxy_path(self) -> Optional[str]:
136
162
  return os.getenv("HYDROLIX_PROXY_PATH")
137
163
 
138
164
  @property
@@ -168,19 +194,83 @@ class HydrolixConfig:
168
194
  """
169
195
  return int(os.getenv("HYDROLIX_MCP_BIND_PORT", "8000"))
170
196
 
171
- def get_client_config(self) -> dict:
172
- """Get the configuration dictionary for clickhouse_connect client.
197
+ @property
198
+ def mcp_timeout(self) -> int:
199
+ """Get the request timeout secunds.
200
+
201
+ Only used when transport is "http" or "sse".
202
+ Default: 120
203
+ """
204
+ return int(os.getenv("HYDROLIX_MCP_REQUEST_TIMEOUT", 120))
205
+
206
+ @property
207
+ def mcp_workers(self) -> int:
208
+ """Get the number of worker processes.
209
+
210
+ Only used when transport is "http" or "sse".
211
+ Default: 1
212
+ """
213
+ return int(os.getenv("HYDROLIX_MCP_WORKERS", 1))
214
+
215
+ @property
216
+ def mcp_worker_connections(self) -> int:
217
+ """Get the max number of concurrent requests per worker.
218
+
219
+ Only used when transport is "http" or "sse".
220
+ Default: 200
221
+ """
222
+ return int(os.getenv("HYDROLIX_MCP_WORKER_CONNECTIONS", 100))
223
+
224
+ @property
225
+ def mcp_max_requests_jitter(self) -> int:
226
+ """Get the random parameter to randomize time process is reloaded after max_requests.
227
+
228
+ Only used when transport is "http" or "sse".
229
+ Default: 10000
230
+ """
231
+ return int(os.getenv("HYDROLIX_MCP_MAX_REQUESTS_JITTER", 1000))
232
+
233
+ @property
234
+ def mcp_max_requests(self) -> int:
235
+ """Get the max number of requests handled by worker before it is restarted.
236
+
237
+ Only used when transport is "http" or "sse".
238
+ Default: 1000
239
+ """
240
+ return int(os.getenv("HYDROLIX_MCP_MAX_REQUESTS", 10000))
241
+
242
+ @property
243
+ def mcp_keepalive(self) -> int:
244
+ """Get a seconds of idle keepalive connections are kept alive.
245
+
246
+ Only used when transport is "http" or "sse".
247
+ Default: 10
248
+ """
249
+ return int(os.getenv("HYDROLIX_MCP_MAX_KEEPALIVE", 10))
250
+
251
+ def get_client_config(self, request_credential: Optional[HydrolixCredential]) -> dict:
252
+ """
253
+ Get the configuration dictionary for clickhouse_connect client.
254
+
255
+ Args:
256
+ request_credential: Optional credentials to use for this request. If not provided,
257
+ falls back to the default credential for this HydrolixConfig
173
258
 
174
259
  Returns:
175
260
  dict: Configuration ready to be passed to clickhouse_connect.get_client()
261
+
262
+ Raises:
263
+ ValueError: If no credentials could be inferred for the request (either from
264
+ the startup environment or provided in the request)
176
265
  """
177
266
  config = {
178
267
  "host": self.host,
179
268
  "port": self.port,
180
- "secure": True,
269
+ "secure": self.secure,
181
270
  "verify": self.verify,
182
271
  "connect_timeout": self.connect_timeout,
183
272
  "send_receive_timeout": self.send_receive_timeout,
273
+ "executor_threads": self.query_pool_size,
184
274
  "client_name": "mcp_hydrolix",
185
275
  }
186
276
 
@@ -191,29 +281,29 @@ class HydrolixConfig:
191
281
  if self.proxy_path:
192
282
  config["proxy_path"] = self.proxy_path
193
283
 
194
- if self.service_account:
195
- config["access_token"] = self.service_account_token
196
- else:
197
- config["username"] = self.username
198
- config["password"] = self.password
284
+ # Add credentials
285
+ config |= self.creds_with(request_credential).clickhouse_config_entries()
199
286
 
200
287
  return config
201
288
 
202
289
  def _validate_required_vars(self) -> None:
203
- """Validate that all required environment variables are set.
290
+ """Validate that all required environment variables are set. Called during __init__.
204
291
 
205
292
  Raises:
206
293
  ValueError: If any required environment variable is missing.
207
294
  """
208
295
  missing_vars = []
209
- if self.service_account:
210
- required_vars = ["HYDROLIX_HOST", "HYDROLIX_TOKEN"]
211
- else:
212
- required_vars = ["HYDROLIX_HOST", "HYDROLIX_USER", "HYDROLIX_PASSWORD"]
296
+ required_vars = ["HYDROLIX_HOST"]
213
297
  for var in required_vars:
214
298
  if var not in os.environ:
215
299
  missing_vars.append(var)
216
300
 
301
+ # HYDROLIX_USER and HYDROLIX_PASSWORD must either be both present or both absent
302
+ if ("HYDROLIX_USER" in os.environ) != ("HYDROLIX_PASSWORD" in os.environ):
303
+ raise ValueError(
304
+ "User/password authentication is only partially configured: set both HYDROLIX_USER and HYDROLIX_PASSWORD"
305
+ )
306
+
217
307
  if missing_vars:
218
308
  raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
219
309
 
@@ -1,19 +1,32 @@
1
- import logging
2
1
  import json
3
- from typing import Optional, List, Any
4
- import concurrent.futures
5
- import atexit
2
+ import logging
3
+ import signal
4
+ from collections.abc import Sequence
5
+ from dataclasses import asdict, is_dataclass
6
+ from typing import Any, Final, Optional, List, cast, TypedDict
6
7
 
7
8
  import clickhouse_connect
9
+ from clickhouse_connect import common
10
+ from clickhouse_connect.driver import httputil
8
11
  from clickhouse_connect.driver.binding import format_query_value
9
12
  from dotenv import load_dotenv
10
13
  from fastmcp import FastMCP
11
14
  from fastmcp.exceptions import ToolError
12
- from dataclasses import dataclass, field, asdict, is_dataclass
15
+ from fastmcp.server.dependencies import get_access_token
16
+ from pydantic import Field
17
+ from pydantic.dataclasses import dataclass
13
18
  from starlette.requests import Request
14
19
  from starlette.responses import PlainTextResponse
15
20
 
16
- from mcp_hydrolix.mcp_env import get_config
21
+ from .auth import (
22
+ AccessToken,
23
+ HydrolixCredential,
24
+ HydrolixCredentialChain,
25
+ ServiceAccountToken,
26
+ UsernamePassword,
27
+ )
28
+ from .mcp_env import HydrolixConfig, get_config
29
+ from .utils import with_serializer
17
30
 
18
31
 
19
32
  @dataclass
@@ -33,35 +46,34 @@ class Table:
33
46
  name: str
34
47
  engine: str
35
48
  create_table_query: str
36
- dependencies_database: str
37
- dependencies_table: str
49
+ dependencies_database: List[str]
50
+ dependencies_table: List[str]
38
51
  engine_full: str
39
52
  sorting_key: str
40
53
  primary_key: str
41
- total_rows: int
42
- total_bytes: int
43
- total_bytes_uncompressed: int
44
- parts: int
45
- active_parts: int
46
- total_marks: int
54
+ total_rows: Optional[int]
55
+ total_bytes: Optional[int]
56
+ total_bytes_uncompressed: Optional[int]
57
+ parts: Optional[int]
58
+ active_parts: Optional[int]
59
+ total_marks: Optional[int]
60
+ columns: Optional[List[Column]] = Field([])
47
61
  comment: Optional[str] = None
48
- columns: List[Column] = field(default_factory=list)
49
62
 
50
63
 
51
- MCP_SERVER_NAME = "mcp-hydrolix"
64
+ @dataclass
65
+ class HdxQueryResult(TypedDict):
66
+ columns: List[str]
67
+ rows: List[List[Any]]
52
68
 
53
- # Configure logging
54
- logging.basicConfig(
55
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
56
- )
57
- logger = logging.getLogger(MCP_SERVER_NAME)
58
69
 
59
- QUERY_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10)
60
- atexit.register(lambda: QUERY_EXECUTOR.shutdown(wait=True))
61
- SELECT_QUERY_TIMEOUT_SECS = 30
70
+ MCP_SERVER_NAME = "mcp-hydrolix"
71
+ logger = logging.getLogger(MCP_SERVER_NAME)
62
72
 
63
73
  load_dotenv()
64
74
 
75
+ HYDROLIX_CONFIG: Final[HydrolixConfig] = get_config()
76
+
65
77
  mcp = FastMCP(
66
78
  name=MCP_SERVER_NAME,
67
79
  dependencies=[
@@ -69,9 +81,105 @@ mcp = FastMCP(
69
81
  "python-dotenv",
70
82
  "pip-system-certs",
71
83
  ],
84
+ auth=HydrolixCredentialChain(f"https://{HYDROLIX_CONFIG.host}/config"),
72
85
  )
73
86
 
74
87
 
88
+ def get_request_credential() -> Optional[HydrolixCredential]:
89
+ if (token := get_access_token()) is not None:
90
+ if isinstance(token, AccessToken):
91
+ return token.as_credential()
92
+ else:
93
+ raise ValueError(
94
+ "Found non-hydrolix access token on request -- this should be impossible!"
95
+ )
96
+ return None
97
+
98
+
99
+ async def create_hydrolix_client(pool_mgr, request_credential: Optional[HydrolixCredential]):
100
+ """
101
+ Create a client for operations against query-head. Note that this eagerly issues requests for initialization
102
+ of properties like `server_version`, and so may throw exceptions.
103
+ INV: clients returned by this method MUST NOT be reused across sessions, because they can close over per-session
104
+ credentials.
105
+ """
106
+ creds = HYDROLIX_CONFIG.creds_with(request_credential)
107
+ auth_info = (
108
+ f"as {creds.username}"
109
+ if isinstance(creds, UsernamePassword)
110
+ else f"using service account {cast(ServiceAccountToken, creds).service_account_id}"
111
+ )
112
+ logger.info(
113
+ f"Creating Hydrolix client connection to {HYDROLIX_CONFIG.host}:{HYDROLIX_CONFIG.port} "
114
+ f"{auth_info} "
115
+ f"(connect_timeout={HYDROLIX_CONFIG.connect_timeout}s, "
116
+ f"send_receive_timeout={HYDROLIX_CONFIG.send_receive_timeout}s)"
117
+ )
118
+
119
+ try:
120
+ client = await clickhouse_connect.get_async_client(
121
+ pool_mgr=pool_mgr, **HYDROLIX_CONFIG.get_client_config(request_credential)
122
+ )
123
+ # Test the connection
124
+ version = client.client.server_version
125
+ logger.info(f"Successfully connected to Hydrolix compatible with ClickHouse {version}")
126
+ return client
127
+ except Exception as e:
128
+ logger.error(f"Failed to connect to Hydrolix: {str(e)}")
129
+ raise
130
+
131
+
132
+ # allow custom hydrolix settings in CH client
133
+ common.set_setting("invalid_setting_action", "send")
134
+ common.set_setting("autogenerate_session_id", False)
135
+ client_shared_pool = httputil.get_pool_manager(maxsize=HYDROLIX_CONFIG.query_pool_size, num_pools=1)
136
+
137
+
138
+ def term(*args, **kwargs):
139
+ client_shared_pool.clear()
140
+
141
+
142
+ signal.signal(signal.SIGTERM, term)
143
+ signal.signal(signal.SIGINT, term)
144
+ signal.signal(signal.SIGQUIT, term)
145
+
146
+
147
+ async def execute_query(query: str) -> HdxQueryResult:
148
+ try:
149
+ async with await create_hydrolix_client(
150
+ client_shared_pool, get_request_credential()
151
+ ) as client:
152
+ res = await client.query(
153
+ query,
154
+ settings={
155
+ "readonly": 1,
156
+ "hdx_query_max_execution_time": HYDROLIX_CONFIG.query_timeout_sec,
157
+ "hdx_query_max_attempts": 1,
158
+ "hdx_query_max_result_rows": 100_000,
159
+ "hdx_query_max_memory_usage": 2 * 1024 * 1024 * 1024, # 2GiB
160
+ "hdx_query_admin_comment": f"User: {MCP_SERVER_NAME}",
161
+ },
162
+ )
163
+ logger.info(f"Query returned {len(res.result_rows)} rows")
164
+ return HdxQueryResult(columns=res.column_names, rows=res.result_rows)
165
+ except Exception as err:
166
+ logger.error(f"Error executing query: {err}")
167
+ raise ToolError(f"Query execution failed: {str(err)}")
168
+
169
+
170
+ async def execute_cmd(query: str):
171
+ try:
172
+ async with await create_hydrolix_client(
173
+ client_shared_pool, get_request_credential()
174
+ ) as client:
175
+ res = await client.command(query)
176
+ logger.info("Command returned executed.")
177
+ return res
178
+ except Exception as err:
179
+ logger.error(f"Error executing command: {err}")
180
+ raise ToolError(f"Command execution failed: {str(err)}")
181
+
182
+
75
183
  @mcp.custom_route("/health", methods=["GET"])
76
184
  async def health_check(request: Request) -> PlainTextResponse:
77
185
  """Health check endpoint for monitoring server status.
@@ -80,8 +188,10 @@ async def health_check(request: Request) -> PlainTextResponse:
80
188
  """
81
189
  try:
82
190
  # Try to create a client connection to verify query-head connectivity
83
- client = create_hydrolix_client()
84
- version = client.server_version
191
+ async with await create_hydrolix_client(
192
+ client_shared_pool, get_request_credential()
193
+ ) as client:
194
+ version = client.client.server_version
85
195
  return PlainTextResponse(f"OK - Connected to Hydrolix compatible with ClickHouse {version}")
86
196
  except Exception as e:
87
197
  # Return 503 Service Unavailable if we can't connect to Hydrolix
@@ -97,21 +207,24 @@ def result_to_column(query_columns, result) -> List[Column]:
97
207
 
98
208
 
99
209
  def to_json(obj: Any) -> str:
210
+ # This function technically returns different types:
211
+ # - str for dataclasses (the primary use case)
212
+ # - list/dict/Any for recursive processing during serialization
213
+ # Type checking is suppressed for non-str returns as they're only used internally by json.dumps
100
214
  if is_dataclass(obj):
101
215
  return json.dumps(asdict(obj), default=to_json)
102
216
  elif isinstance(obj, list):
103
- return [to_json(item) for item in obj]
217
+ return [to_json(item) for item in obj] # type: ignore[return-value]
104
218
  elif isinstance(obj, dict):
105
- return {key: to_json(value) for key, value in obj.items()}
106
- return obj
219
+ return {key: to_json(value) for key, value in obj.items()} # type: ignore[return-value]
220
+ return obj # type: ignore[return-value]
107
221
 
108
222
 
109
223
  @mcp.tool()
110
- def list_databases():
224
+ async def list_databases() -> List[str]:
111
225
  """List available Hydrolix databases"""
112
226
  logger.info("Listing all databases")
113
- client = create_hydrolix_client()
114
- result = client.command("SHOW DATABASES")
227
+ result = await execute_cmd("SHOW DATABASES")
115
228
 
116
229
  # Convert newline-separated string to list and trim whitespace
117
230
  if isinstance(result, str):
@@ -120,65 +233,53 @@ def list_databases():
120
233
  databases = [result]
121
234
 
122
235
  logger.info(f"Found {len(databases)} databases")
123
- return json.dumps(databases)
236
+ return databases
124
237
 
125
238
 
126
239
  @mcp.tool()
127
- def list_tables(database: str, like: Optional[str] = None, not_like: Optional[str] = None):
240
+ async def list_tables(
241
+ database: str, like: Optional[str] = None, not_like: Optional[str] = None
242
+ ) -> List[Table]:
128
243
  """List available Hydrolix tables in a database, including schema, comment,
129
244
  row count, and column count."""
130
245
  logger.info(f"Listing tables in database '{database}'")
131
- client = create_hydrolix_client()
132
- query = f"SELECT database, name, engine, create_table_query, dependencies_database, dependencies_table, engine_full, sorting_key, primary_key, total_rows, total_bytes, total_bytes_uncompressed, parts, active_parts, total_marks, comment FROM system.tables WHERE database = {format_query_value(database)}"
246
+ query = f"""
247
+ SELECT database, name, engine, create_table_query, dependencies_database,
248
+ dependencies_table, engine_full, sorting_key, primary_key, total_rows, total_bytes,
249
+ total_bytes_uncompressed, parts, active_parts, total_marks, comment
250
+ FROM system.tables WHERE database = {format_query_value(database)}"""
133
251
  if like:
134
252
  query += f" AND name LIKE {format_query_value(like)}"
135
253
 
136
254
  if not_like:
137
255
  query += f" AND name NOT LIKE {format_query_value(not_like)}"
138
256
 
139
- result = client.query(query)
257
+ result = await execute_query(query)
140
258
 
141
259
  # Deserialize result as Table dataclass instances
142
- tables = result_to_table(result.column_names, result.result_rows)
260
+ tables = result_to_table(result["columns"], result["rows"])
143
261
 
144
262
  for table in tables:
145
- column_data_query = f"SELECT database, table, name, type AS column_type, default_kind, default_expression, comment FROM system.columns WHERE database = {format_query_value(database)} AND table = {format_query_value(table.name)}"
146
- column_data_query_result = client.query(column_data_query)
263
+ column_data_query = f"""
264
+ SELECT database, table, name, type AS column_type, default_kind, default_expression, comment
265
+ FROM system.columns
266
+ WHERE database = {format_query_value(database)} AND table = {format_query_value(table.name)}"""
267
+ column_data_query_result = await execute_query(column_data_query)
147
268
  table.columns = [
148
269
  c
149
270
  for c in result_to_column(
150
- column_data_query_result.column_names,
151
- column_data_query_result.result_rows,
271
+ column_data_query_result["columns"],
272
+ column_data_query_result["rows"],
152
273
  )
153
274
  ]
154
275
 
155
276
  logger.info(f"Found {len(tables)} tables")
156
- return [asdict(table) for table in tables]
157
-
158
-
159
- def execute_query(query: str):
160
- client = create_hydrolix_client()
161
- try:
162
- res = client.query(
163
- query,
164
- settings={
165
- "readonly": 1,
166
- "hdx_query_max_execution_time": SELECT_QUERY_TIMEOUT_SECS,
167
- "hdx_query_max_attempts": 1,
168
- "hdx_query_max_result_rows": 100_000,
169
- "hdx_query_max_memory_usage": 2 * 1024 * 1024 * 1024, # 2GiB
170
- "hdx_query_admin_comment": f"User: {MCP_SERVER_NAME}",
171
- },
172
- )
173
- logger.info(f"Query returned {len(res.result_rows)} rows")
174
- return {"columns": res.column_names, "rows": res.result_rows}
175
- except Exception as err:
176
- logger.error(f"Error executing query: {err}")
177
- raise ToolError(f"Query execution failed: {str(err)}")
277
+ return tables
178
278
 
179
279
 
180
280
  @mcp.tool()
181
- def run_select_query(query: str):
281
+ @with_serializer
282
+ async def run_select_query(query: str) -> dict[str, tuple | Sequence[str | Sequence[Any]]]:
182
283
  """Run a SELECT query in a Hydrolix time-series database using the Clickhouse SQL dialect.
183
284
  Queries run using this tool will timeout after 30 seconds.
184
285
 
@@ -218,51 +319,8 @@ def run_select_query(query: str):
218
319
  """
219
320
  logger.info(f"Executing SELECT query: {query}")
220
321
  try:
221
- future = QUERY_EXECUTOR.submit(execute_query, query)
222
- try:
223
- result = future.result(timeout=SELECT_QUERY_TIMEOUT_SECS)
224
- # Check if we received an error structure from execute_query
225
- if isinstance(result, dict) and "error" in result:
226
- logger.warning(f"Query failed: {result['error']}")
227
- # MCP requires structured responses; string error messages can cause
228
- # serialization issues leading to BrokenResourceError
229
- return {
230
- "status": "error",
231
- "message": f"Query failed: {result['error']}",
232
- }
233
- return result
234
- except concurrent.futures.TimeoutError:
235
- logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
236
- future.cancel()
237
- raise ToolError(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds")
238
- except ToolError:
239
- raise
322
+ result = await execute_query(query=query)
323
+ return result
240
324
  except Exception as e:
241
325
  logger.error(f"Unexpected error in run_select_query: {str(e)}")
242
- raise RuntimeError(f"Unexpected error during query execution: {str(e)}")
243
-
244
-
245
- def create_hydrolix_client():
246
- client_config = get_config().get_client_config()
247
- auth_info = (
248
- f"as {client_config['username']}"
249
- if "username" in client_config
250
- else "using service account token"
251
- )
252
- logger.info(
253
- f"Creating Hydrolix client connection to {client_config['host']}:{client_config['port']} "
254
- f"{auth_info} "
255
- f"(secure={client_config['secure']}, verify={client_config['verify']}, "
256
- f"connect_timeout={client_config['connect_timeout']}s, "
257
- f"send_receive_timeout={client_config['send_receive_timeout']}s)"
258
- )
259
-
260
- try:
261
- client = clickhouse_connect.get_client(**client_config)
262
- # Test the connection
263
- version = client.server_version
264
- logger.info(f"Successfully connected to Hydrolix compatible with ClickHouse {version}")
265
- return client
266
- except Exception as e:
267
- logger.error(f"Failed to connect to Hydrolix: {str(e)}")
268
- raise
326
+ raise ToolError(f"Unexpected error during query execution: {str(e)}")