-
Notifications
You must be signed in to change notification settings - Fork 197
Expand file tree
/
Copy pathmcp_server.py
More file actions
732 lines (609 loc) · 26 KB
/
Copy pathmcp_server.py
File metadata and controls
732 lines (609 loc) · 26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
import logging
import json
from typing import Optional, List, Any, Dict
import concurrent.futures
import atexit
import os
import re
import uuid
import clickhouse_connect
from clickhouse_connect.driver.binding import format_query_value
from dotenv import load_dotenv
from fastmcp import FastMCP
from cachetools import TTLCache
from fastmcp.tools import Tool
from fastmcp.prompts import Prompt
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_context
from dataclasses import dataclass, field, asdict, is_dataclass
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from mcp_clickhouse.mcp_env import get_config, get_chdb_config, get_mcp_config, TransportType
from mcp_clickhouse.chdb_prompt import CHDB_PROMPT
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
@dataclass
class Column:
database: str
table: str
name: str
column_type: str
default_kind: Optional[str]
default_expression: Optional[str]
comment: Optional[str]
@dataclass
class Table:
database: str
name: str
engine: str
create_table_query: str
dependencies_database: str
dependencies_table: str
engine_full: str
sorting_key: str
primary_key: str
total_rows: int
total_bytes: int
total_bytes_uncompressed: int
parts: int
active_parts: int
total_marks: int
comment: Optional[str] = None
columns: List[Column] = field(default_factory=list)
MCP_SERVER_NAME = "mcp-clickhouse"
CLIENT_CONFIG_OVERRIDES_KEY = "clickhouse_client_config_overrides"
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(MCP_SERVER_NAME)
QUERY_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10)
atexit.register(lambda: QUERY_EXECUTOR.shutdown(wait=True))
load_dotenv()
# Configure authentication for HTTP/SSE transports
auth_provider = None
mcp_config = get_mcp_config()
http_transports = [TransportType.HTTP.value, TransportType.SSE.value]
if mcp_config.server_transport in http_transports:
if mcp_config.auth_disabled:
logger.warning("WARNING: MCP SERVER AUTHENTICATION IS DISABLED")
logger.warning("Only use this for local development/testing.")
logger.warning("DO NOT expose to networks.")
elif mcp_config.auth_token:
auth_provider = StaticTokenVerifier(
tokens={mcp_config.auth_token: {"client_id": "mcp-client", "scopes": []}},
required_scopes=[],
)
logger.info("Authentication enabled for HTTP/SSE transport")
else:
# No token configured and auth not disabled
raise ValueError(
"Authentication token required for HTTP/SSE transports. "
"Set CLICKHOUSE_MCP_AUTH_TOKEN environment variable or set "
"CLICKHOUSE_MCP_AUTH_DISABLED=true (for development only)."
)
mcp = FastMCP(name=MCP_SERVER_NAME, auth=auth_provider)
@mcp.custom_route("/health", methods=["GET"])
async def health_check(request: Request) -> PlainTextResponse:
"""Health check endpoint for monitoring server status.
Returns OK if the server is running and can connect to ClickHouse.
"""
if auth_provider is not None:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return PlainTextResponse("Unauthorized", status_code=401)
token = auth_header[7:]
access_token = await auth_provider.verify_token(token)
if access_token is None:
return PlainTextResponse("Unauthorized", status_code=401)
try:
# Check if ClickHouse is enabled by trying to create config
# If ClickHouse is disabled, this will succeed but connection will fail
clickhouse_enabled = os.getenv("CLICKHOUSE_ENABLED", "true").lower() == "true"
if not clickhouse_enabled:
# If ClickHouse is disabled, check chDB status
chdb_config = get_chdb_config()
if chdb_config.enabled:
return PlainTextResponse("OK - MCP server running with chDB enabled")
else:
# Both ClickHouse and chDB are disabled - this is an error
return PlainTextResponse(
"ERROR - Both ClickHouse and chDB are disabled. At least one must be enabled.",
status_code=503,
)
# Try to create a client connection to verify ClickHouse connectivity
client = create_clickhouse_client()
version = client.server_version
return PlainTextResponse(f"OK - Connected to ClickHouse {version}")
except Exception as e:
# Return 503 Service Unavailable if we can't connect to ClickHouse
return PlainTextResponse(f"ERROR - Cannot connect to ClickHouse: {str(e)}", status_code=503)
def result_to_table(query_columns, result) -> List[Table]:
return [Table(**dict(zip(query_columns, row))) for row in result]
def result_to_column(query_columns, result) -> List[Column]:
return [Column(**dict(zip(query_columns, row))) for row in result]
def to_json(obj: Any) -> str:
if is_dataclass(obj):
return json.dumps(asdict(obj), default=to_json)
elif isinstance(obj, list):
return [to_json(item) for item in obj]
elif isinstance(obj, dict):
return {key: to_json(value) for key, value in obj.items()}
return obj
def list_databases():
"""List available ClickHouse databases"""
logger.info("Listing all databases")
client = create_clickhouse_client()
result = client.command("SHOW DATABASES")
# Convert newline-separated string to list and trim whitespace
if isinstance(result, str):
databases = [db.strip() for db in result.strip().split("\n")]
else:
databases = [result]
logger.info(f"Found {len(databases)} databases")
return json.dumps(databases)
# Store pagination state for list_tables with 1-hour expiry
# Using TTLCache from cachetools to automatically expire entries after 1 hour
table_pagination_cache: TTLCache = TTLCache(maxsize=100, ttl=3600) # 3600 seconds = 1 hour
def fetch_table_names_from_system(
client,
database: str,
like: Optional[str] = None,
not_like: Optional[str] = None,
) -> List[str]:
"""Get list of table names from system.tables.
Args:
client: ClickHouse client
database: Database name
like: Optional pattern to filter table names (LIKE)
not_like: Optional pattern to filter out table names (NOT LIKE)
Returns:
List of table names
"""
query = f"SELECT name FROM system.tables WHERE database = {format_query_value(database)}"
if like:
query += f" AND name LIKE {format_query_value(like)}"
if not_like:
query += f" AND name NOT LIKE {format_query_value(not_like)}"
result = client.query(query)
table_names = [row[0] for row in result.result_rows]
return table_names
def get_paginated_table_data(
client,
database: str,
table_names: List[str],
start_idx: int,
page_size: int,
include_detailed_columns: bool = True,
) -> tuple[List[Table], int, bool]:
"""Get detailed information for a page of tables.
Args:
client: ClickHouse client
database: Database name
table_names: List of all table names to paginate
start_idx: Starting index for pagination
page_size: Number of tables per page
include_detailed_columns: Whether to include detailed column metadata (default: True)
Returns:
Tuple of (list of Table objects, end index, has more pages)
"""
end_idx = min(start_idx + page_size, len(table_names))
current_page_table_names = table_names[start_idx:end_idx]
if not current_page_table_names:
return [], end_idx, False
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)}
AND name IN ({", ".join(format_query_value(name) for name in current_page_table_names)})
"""
result = client.query(query)
tables = result_to_table(result.column_names, result.result_rows)
if include_detailed_columns:
for table in tables:
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)}
"""
column_data_query_result = client.query(column_data_query)
table.columns = result_to_column(
column_data_query_result.column_names,
column_data_query_result.result_rows,
)
else:
for table in tables:
table.columns = []
return tables, end_idx, end_idx < len(table_names)
def create_page_token(
database: str,
like: Optional[str],
not_like: Optional[str],
table_names: List[str],
end_idx: int,
include_detailed_columns: bool,
) -> str:
"""Create a new page token and store it in the cache.
Args:
database: Database name
like: LIKE pattern used to filter tables
not_like: NOT LIKE pattern used to filter tables
table_names: List of all table names
end_idx: Index to start from for the next page
include_detailed_columns: Whether to include detailed column metadata
Returns:
New page token
"""
token = str(uuid.uuid4())
table_pagination_cache[token] = {
"database": database,
"like": like,
"not_like": not_like,
"table_names": table_names,
"start_idx": end_idx,
"include_detailed_columns": include_detailed_columns,
}
return token
def list_tables(
database: str,
like: Optional[str] = None,
not_like: Optional[str] = None,
page_token: Optional[str] = None,
page_size: int = 50,
include_detailed_columns: bool = True,
) -> Dict[str, Any]:
"""List available ClickHouse tables in a database, including schema, comment,
row count, and column count.
Args:
database: The database to list tables from
like: Optional LIKE pattern to filter table names
not_like: Optional NOT LIKE pattern to exclude table names
page_token: Token for pagination, obtained from a previous call
page_size: Number of tables to return per page (default: 50)
include_detailed_columns: Whether to include detailed column metadata (default: True).
When False, the columns array will be empty but create_table_query still contains
all column information. This reduces payload size for large schemas.
Returns:
A dictionary containing:
- tables: List of table information (as dictionaries)
- next_page_token: Token for the next page, or None if no more pages
- total_tables: Total number of tables matching the filters
"""
logger.info(
"Listing tables in database '%s' with like=%s, not_like=%s, "
"page_token=%s, page_size=%s, include_detailed_columns=%s",
database,
like,
not_like,
page_token,
page_size,
include_detailed_columns,
)
client = create_clickhouse_client()
if page_token and page_token in table_pagination_cache:
cached_state = table_pagination_cache[page_token]
cached_include_detailed = cached_state.get("include_detailed_columns", True)
if (
cached_state["database"] != database
or cached_state["like"] != like
or cached_state["not_like"] != not_like
or cached_include_detailed != include_detailed_columns
):
logger.warning(
"Page token %s is for a different database, filter, or metadata setting. "
"Ignoring token and starting from beginning.",
page_token,
)
page_token = None
else:
table_names = cached_state["table_names"]
start_idx = cached_state["start_idx"]
tables, end_idx, has_more = get_paginated_table_data(
client,
database,
table_names,
start_idx,
page_size,
include_detailed_columns,
)
next_page_token = None
if has_more:
next_page_token = create_page_token(
database, like, not_like, table_names, end_idx, include_detailed_columns
)
del table_pagination_cache[page_token]
logger.info(
"Returned page with %s tables (total: %s), next_page_token=%s",
len(tables),
len(table_names),
next_page_token,
)
return {
"tables": [asdict(table) for table in tables],
"next_page_token": next_page_token,
"total_tables": len(table_names),
}
table_names = fetch_table_names_from_system(client, database, like, not_like)
start_idx = 0
tables, end_idx, has_more = get_paginated_table_data(
client,
database,
table_names,
start_idx,
page_size,
include_detailed_columns,
)
next_page_token = None
if has_more:
next_page_token = create_page_token(
database, like, not_like, table_names, end_idx, include_detailed_columns
)
logger.info(
"Found %s tables, returning %s with next_page_token=%s",
len(table_names),
len(tables),
next_page_token,
)
return {
"tables": [asdict(table) for table in tables],
"next_page_token": next_page_token,
"total_tables": len(table_names),
}
def _validate_query_for_destructive_ops(query: str) -> None:
"""Validate that destructive operations (DROP, TRUNCATE) are allowed."""
config = get_config()
# 🛡️ SECURITY PATCH: Enforce strict read-only at the application layer
if not config.allow_write_access:
# Strip comments to prevent SQL injection style bypasses
clean_query = re.sub(r'--.*$', '', query, flags=re.MULTILINE)
clean_query = re.sub(r'/\*.*?\*/', '', clean_query, flags=re.DOTALL)
clean_query = clean_query.strip().upper()
allowed_prefixes = ('SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'EXISTS')
if not clean_query.startswith(allowed_prefixes):
raise ToolError(
"Security Violation: Only SELECT/SHOW/DESCRIBE queries are allowed. "
"Data modification is strictly prohibited in this environment unless "
"CLICKHOUSE_ALLOW_WRITE_ACCESS=true is set."
)
return
# If DROP is explicitly allowed, no validation needed
if config.allow_drop:
return
# Simple pattern matching for destructive operations
destructive_pattern = r'\b(DROP\s+(\S+\s+)*(TABLE|DATABASE|VIEW|DICTIONARY)|TRUNCATE\s+TABLE)\b'
if re.search(destructive_pattern, query, re.IGNORECASE):
raise ToolError(
"Destructive operations (DROP, TRUNCATE) are not allowed. "
"Set CLICKHOUSE_ALLOW_DROP=true to enable these operations. "
"This is a safety feature to prevent accidental data deletion."
)
def execute_query(query: str):
client = create_clickhouse_client()
try:
_validate_query_for_destructive_ops(query)
query_settings = build_query_settings(client)
res = client.query(query, settings=query_settings)
logger.info(f"Query returned {len(res.result_rows)} rows")
return {"columns": res.column_names, "rows": res.result_rows}
except ToolError:
raise
except Exception as err:
logger.error(f"Error executing query: {err}")
raise ToolError(f"Query execution failed: {str(err)}")
def run_query(query: str):
"""Execute a SQL query against ClickHouse.
Queries run in read-only mode by default. Set CLICKHOUSE_ALLOW_WRITE_ACCESS=true
to allow DDL and DML statements when your ClickHouse server permits them.
"""
logger.info(f"Executing query: {query}")
try:
future = QUERY_EXECUTOR.submit(execute_query, query)
try:
timeout_secs = get_mcp_config().query_timeout
result = future.result(timeout=timeout_secs)
# Check if we received an error structure from execute_query
if isinstance(result, dict) and "error" in result:
logger.warning(f"Query failed: {result['error']}")
# MCP requires structured responses; string error messages can cause
# serialization issues leading to BrokenResourceError
return {
"status": "error",
"message": f"Query failed: {result['error']}",
}
return result
except concurrent.futures.TimeoutError:
logger.warning(f"Query timed out after {timeout_secs} seconds: {query}")
future.cancel()
raise ToolError(f"Query timed out after {timeout_secs} seconds")
except ToolError:
raise
except Exception as e:
logger.error("Unexpected error in run_query: %s", str(e))
raise RuntimeError(f"Unexpected error during query execution: {str(e)}")
def create_clickhouse_client():
client_config = get_config().get_client_config()
try:
ctx = get_context()
session_config_overrides = ctx.get_state(CLIENT_CONFIG_OVERRIDES_KEY)
if session_config_overrides and not isinstance(session_config_overrides, dict):
logger.warning(f"{CLIENT_CONFIG_OVERRIDES_KEY} must be a dict, got {type(session_config_overrides).__name__}. Ignoring.")
elif session_config_overrides:
logger.debug(f"Applying session-specific ClickHouse client config overrides: {list(session_config_overrides.keys())}")
client_config.update(session_config_overrides)
except RuntimeError:
# If we're outside a request context, just proceed with the default config
pass
logger.info(
f"Creating ClickHouse client connection to {client_config['host']}:{client_config['port']} "
f"as {client_config['username']} "
f"(secure={client_config['secure']}, verify={client_config['verify']}, "
f"connect_timeout={client_config['connect_timeout']}s, "
f"send_receive_timeout={client_config['send_receive_timeout']}s)"
)
try:
client = clickhouse_connect.get_client(**client_config)
# Test the connection
version = client.server_version
logger.info(f"Successfully connected to ClickHouse server version {version}")
return client
except Exception as e:
logger.error(f"Failed to connect to ClickHouse: {str(e)}")
raise
def build_query_settings(client) -> dict[str, str]:
"""Build query settings dict for ClickHouse queries.
Always returns a dict (possibly empty) to ensure consistent behavior.
"""
readonly_setting = get_readonly_setting(client)
if readonly_setting is not None:
return {"readonly": readonly_setting}
return {}
def get_readonly_setting(client) -> Optional[str]:
"""Determine the readonly setting value for queries.
This implements the following logic:
1. If CLICKHOUSE_ALLOW_WRITE_ACCESS=true (writes enabled):
- Allow writes if server permits (server readonly=None or "0")
- Fall back to server's readonly setting if server enforces it
- Log a warning when falling back
2. If CLICKHOUSE_ALLOW_WRITE_ACCESS=false (default, read-only mode):
- Enforce readonly=1 if server allows writes
- Respect server's readonly setting if server enforces stricter mode
Returns:
"0" = writes allowed
"1" = read-only mode (allows SET of non-privileged settings)
"2" = strict read-only (server enforced; disallows SET)
None = use server default (shouldn't happen in practice)
"""
config = get_config()
server_settings = getattr(client, "server_settings", {}) or {}
server_readonly = _normalize_readonly_value(server_settings.get("readonly"))
# Case 1: User wants write access (CLICKHOUSE_ALLOW_WRITE_ACCESS=true)
if config.allow_write_access:
if server_readonly in (None, "0"):
logger.info("Write mode enabled (CLICKHOUSE_ALLOW_WRITE_ACCESS=true)")
return "0"
# If server forbids writes, respect server configuration
logger.warning(
"CLICKHOUSE_ALLOW_WRITE_ACCESS=true but server enforces readonly=%s; "
"write operations will fail",
server_readonly,
)
return server_readonly
# Case 2: User wants read-only mode (CLICKHOUSE_ALLOW_WRITE_ACCESS=false, default)
if server_readonly in (None, "0"):
return "1" # Enforce read-only since server allows writes
return server_readonly # Server already enforces readonly, respect it
def _normalize_readonly_value(value: Any) -> Optional[str]:
"""Normalize ClickHouse readonly setting to a simple string.
The clickhouse_connect library represents settings as objects with a .value attribute.
This function extracts the actual value for our logic.
Args:
value: The readonly setting value from ClickHouse server. Can be:
- None (server has no readonly restriction)
- A clickhouse_connect setting object with a .value attribute
- An int (0, 1, 2)
- A str ("0", "1", "2")
Returns:
Optional[str]: Normalized readonly value as string ("0", "1", "2") or None
"""
if value is None:
return None
# Extract value from clickhouse_connect setting object
if hasattr(value, "value"):
value = value.value
return str(value)
def create_chdb_client():
"""Create a chDB client connection."""
if not get_chdb_config().enabled:
raise ValueError("chDB is not enabled. Set CHDB_ENABLED=true to enable it.")
return _chdb_client
def execute_chdb_query(query: str):
"""Execute a query using chDB client."""
client = create_chdb_client()
try:
res = client.query(query, "JSON")
if res.has_error():
error_msg = res.error_message()
logger.error(f"Error executing chDB query: {error_msg}")
return {"error": error_msg}
result_data = res.data()
if not result_data:
return []
result_json = json.loads(result_data)
return result_json.get("data", [])
except Exception as err:
logger.error(f"Error executing chDB query: {err}")
return {"error": str(err)}
def run_chdb_select_query(query: str):
"""Run SQL in chDB, an in-process ClickHouse engine"""
logger.info(f"Executing chDB SELECT query: {query}")
try:
future = QUERY_EXECUTOR.submit(execute_chdb_query, query)
try:
timeout_secs = get_mcp_config().query_timeout
result = future.result(timeout=timeout_secs)
# Check if we received an error structure from execute_chdb_query
if isinstance(result, dict) and "error" in result:
logger.warning(f"chDB query failed: {result['error']}")
return {
"status": "error",
"message": f"chDB query failed: {result['error']}",
}
return result
except concurrent.futures.TimeoutError:
logger.warning(
f"chDB query timed out after {timeout_secs} seconds: {query}"
)
future.cancel()
return {
"status": "error",
"message": f"chDB query timed out after {timeout_secs} seconds",
}
except Exception as e:
logger.error(f"Unexpected error in run_chdb_select_query: {e}")
return {"status": "error", "message": f"Unexpected error: {e}"}
def chdb_initial_prompt() -> str:
"""This prompt helps users understand how to interact and perform common operations in chDB"""
return CHDB_PROMPT
def _init_chdb_client():
"""Initialize the global chDB client instance."""
try:
if not get_chdb_config().enabled:
logger.info("chDB is disabled, skipping client initialization")
return None
client_config = get_chdb_config().get_client_config()
data_path = client_config["data_path"]
logger.info(f"Creating chDB client with data_path={data_path}")
import chdb.session as chs
client = chs.Session(path=data_path)
logger.info(f"Successfully connected to chDB with data_path={data_path}")
return client
except Exception as e:
logger.error(f"Failed to initialize chDB client: {e}")
return None
# Register tools based on configuration
if os.getenv("CLICKHOUSE_ENABLED", "true").lower() == "true":
mcp.add_tool(Tool.from_function(list_databases))
mcp.add_tool(Tool.from_function(list_tables))
mcp.add_tool(Tool.from_function(
run_query,
description=(
"Execute SQL queries in ClickHouse. Queries run in read-only mode by default. "
"Set CLICKHOUSE_ALLOW_WRITE_ACCESS=true to allow DDL and DML operations. "
"Set CLICKHOUSE_ALLOW_DROP=true to additionally allow destructive operations (DROP, TRUNCATE)."
)
))
logger.info("ClickHouse tools registered")
if os.getenv("CHDB_ENABLED", "false").lower() == "true":
_chdb_client = _init_chdb_client()
if _chdb_client:
atexit.register(lambda: _chdb_client.close())
mcp.add_tool(Tool.from_function(run_chdb_select_query))
chdb_prompt = Prompt.from_function(
chdb_initial_prompt,
name="chdb_initial_prompt",
description="This prompt helps users understand how to interact and perform common operations in chDB",
)
mcp.add_prompt(chdb_prompt)
logger.info("chDB tools and prompts registered")