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/auth/__init__.py +29 -0
- mcp_hydrolix/auth/credentials.py +63 -0
- mcp_hydrolix/auth/mcp_providers.py +137 -0
- mcp_hydrolix/log/__init__.py +6 -0
- mcp_hydrolix/log/log.py +60 -0
- mcp_hydrolix/log/log.yaml +40 -0
- mcp_hydrolix/log/utils.py +56 -0
- mcp_hydrolix/main.py +58 -2
- mcp_hydrolix/mcp_env.py +142 -52
- mcp_hydrolix/mcp_server.py +169 -111
- mcp_hydrolix/utils.py +70 -0
- {mcp_hydrolix-0.1.4.dist-info → mcp_hydrolix-0.1.5.dist-info}/METADATA +70 -7
- mcp_hydrolix-0.1.5.dist-info/RECORD +17 -0
- {mcp_hydrolix-0.1.4.dist-info → mcp_hydrolix-0.1.5.dist-info}/WHEEL +1 -1
- mcp_hydrolix-0.1.4.dist-info/RECORD +0 -9
- {mcp_hydrolix-0.1.4.dist-info → mcp_hydrolix-0.1.5.dist-info}/entry_points.txt +0 -0
- {mcp_hydrolix-0.1.4.dist-info → mcp_hydrolix-0.1.5.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
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":
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
mcp_hydrolix/mcp_server.py
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
import logging
|
|
2
1
|
import json
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
236
|
+
return databases
|
|
124
237
|
|
|
125
238
|
|
|
126
239
|
@mcp.tool()
|
|
127
|
-
def list_tables(
|
|
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
|
-
|
|
132
|
-
|
|
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 =
|
|
257
|
+
result = await execute_query(query)
|
|
140
258
|
|
|
141
259
|
# Deserialize result as Table dataclass instances
|
|
142
|
-
tables = result_to_table(result
|
|
260
|
+
tables = result_to_table(result["columns"], result["rows"])
|
|
143
261
|
|
|
144
262
|
for table in tables:
|
|
145
|
-
column_data_query = f"
|
|
146
|
-
|
|
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
|
|
151
|
-
column_data_query_result
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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)}")
|