Skip to content

Commit ac7a66b

Browse files
jasimmkclaude
andcommitted
Merge latest changes from main branch
- Integrate new dataclass-based Table and Column structure - Preserve streaming HTTP support functionality - Merge improved list_tables implementation with enhanced schema info - Add testing instructions from main branch 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2 parents e497854 + 19b4e7d commit ac7a66b

File tree

2 files changed

+125
-98
lines changed

2 files changed

+125
-98
lines changed

README.md

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# ClickHouse MCP Server
2+
23
[![PyPI - Version](https://img.shields.io/pypi/v/mcp-clickhouse)](https://pypi.org/project/mcp-clickhouse)
34

45
An MCP server for ClickHouse.
@@ -10,22 +11,22 @@ An MCP server for ClickHouse.
1011
### Tools
1112

1213
* `run_select_query`
13-
- Execute SQL queries on your ClickHouse cluster.
14-
- Input: `sql` (string): The SQL query to execute.
15-
- All ClickHouse queries are run with `readonly = 1` to ensure they are safe.
14+
* Execute SQL queries on your ClickHouse cluster.
15+
* Input: `sql` (string): The SQL query to execute.
16+
* All ClickHouse queries are run with `readonly = 1` to ensure they are safe.
1617

1718
* `list_databases`
18-
- List all databases on your ClickHouse cluster.
19+
* List all databases on your ClickHouse cluster.
1920

2021
* `list_tables`
21-
- List all tables in a database.
22-
- Input: `database` (string): The name of the database.
22+
* List all tables in a database.
23+
* Input: `database` (string): The name of the database.
2324

2425
## Configuration
2526

2627
1. Open the Claude Desktop configuration file located at:
27-
- On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
28-
- On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
28+
* On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
29+
* On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
2930

3031
2. Add the following:
3132

@@ -89,7 +90,6 @@ Or, if you'd like to try it out with the [ClickHouse SQL Playground](https://sql
8990
}
9091
```
9192

92-
9393
3. Locate the command entry for `uv` and replace it with the absolute path to the `uv` executable. This ensures that the correct version of `uv` is used when starting the server. On a mac, you can find this path using `which uv`.
9494

9595
4. Restart Claude Desktop to apply the changes.
@@ -102,7 +102,7 @@ Or, if you'd like to try it out with the [ClickHouse SQL Playground](https://sql
102102

103103
*Note: The use of the `default` user in this context is intended solely for local development purposes.*
104104

105-
```
105+
```bash
106106
CLICKHOUSE_HOST=localhost
107107
CLICKHOUSE_PORT=8123
108108
CLICKHOUSE_USER=default
@@ -118,36 +118,39 @@ CLICKHOUSE_PASSWORD=clickhouse
118118
The following environment variables are used to configure the ClickHouse connection:
119119

120120
#### Required Variables
121+
121122
* `CLICKHOUSE_HOST`: The hostname of your ClickHouse server
122123
* `CLICKHOUSE_USER`: The username for authentication
123124
* `CLICKHOUSE_PASSWORD`: The password for authentication
124125

125-
> [!CAUTION]
126+
> [!CAUTION]
126127
> It is important to treat your MCP database user as you would any external client connecting to your database, granting only the minimum necessary privileges required for its operation. The use of default or administrative users should be strictly avoided at all times.
127128
128129
#### Optional Variables
130+
129131
* `CLICKHOUSE_PORT`: The port number of your ClickHouse server
130-
- Default: `8443` if HTTPS is enabled, `8123` if disabled
131-
- Usually doesn't need to be set unless using a non-standard port
132+
* Default: `8443` if HTTPS is enabled, `8123` if disabled
133+
* Usually doesn't need to be set unless using a non-standard port
132134
* `CLICKHOUSE_SECURE`: Enable/disable HTTPS connection
133-
- Default: `"true"`
134-
- Set to `"false"` for non-secure connections
135+
* Default: `"true"`
136+
* Set to `"false"` for non-secure connections
135137
* `CLICKHOUSE_VERIFY`: Enable/disable SSL certificate verification
136-
- Default: `"true"`
137-
- Set to `"false"` to disable certificate verification (not recommended for production)
138+
* Default: `"true"`
139+
* Set to `"false"` to disable certificate verification (not recommended for production)
138140
* `CLICKHOUSE_CONNECT_TIMEOUT`: Connection timeout in seconds
139-
- Default: `"30"`
140-
- Increase this value if you experience connection timeouts
141+
* Default: `"30"`
142+
* Increase this value if you experience connection timeouts
141143
* `CLICKHOUSE_SEND_RECEIVE_TIMEOUT`: Send/receive timeout in seconds
142-
- Default: `"300"`
143-
- Increase this value for long-running queries
144+
* Default: `"300"`
145+
* Increase this value for long-running queries
144146
* `CLICKHOUSE_DATABASE`: Default database to use
145-
- Default: None (uses server default)
146-
- Set this to automatically connect to a specific database
147+
* Default: None (uses server default)
148+
* Set this to automatically connect to a specific database
147149

148150
#### Example Configurations
149151

150152
For local development with Docker:
153+
151154
```env
152155
# Required variables
153156
CLICKHOUSE_HOST=localhost
@@ -160,6 +163,7 @@ CLICKHOUSE_VERIFY=false
160163
```
161164

162165
For ClickHouse Cloud:
166+
163167
```env
164168
# Required variables
165169
CLICKHOUSE_HOST=your-instance.clickhouse.cloud
@@ -172,6 +176,7 @@ CLICKHOUSE_PASSWORD=your-password
172176
```
173177

174178
For ClickHouse SQL Playground:
179+
175180
```env
176181
CLICKHOUSE_HOST=sql-clickhouse.clickhouse.com
177182
CLICKHOUSE_USER=demo
@@ -204,6 +209,17 @@ You can set these variables in your environment, in a `.env` file, or in the Cla
204209
}
205210
}
206211
```
212+
213+
### Running tests
214+
215+
```bash
216+
uv sync --all-extras --dev # install dev dependencies
217+
uv run ruff check . # run linting
218+
219+
docker compose up -d test_services # start ClickHouse
220+
uv run pytest tests
221+
```
222+
207223
## YouTube Overview
208224

209225
[![YouTube](http://i.ytimg.com/vi/y9biAm_Fkqw/hqdefault.jpg)](https://www.youtube.com/watch?v=y9biAm_Fkqw)

mcp_clickhouse/mcp_server.py

Lines changed: 86 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,53 @@
11
import logging
2-
from typing import Sequence
2+
import json
3+
from typing import Optional, List, Any
34
import concurrent.futures
45
import atexit
56

67
import clickhouse_connect
7-
from clickhouse_connect.driver.binding import quote_identifier, format_query_value
8+
from clickhouse_connect.driver.binding import format_query_value
89
from dotenv import load_dotenv
910
from mcp.server.fastmcp import FastMCP
1011
from mcp.server.session import ServerSession
1112
from mcp.server.stdio import stdio_server
1213
from mcp.types import ServerCapabilities
14+
from dataclasses import dataclass, field, asdict, is_dataclass
1315

1416
from mcp_clickhouse.mcp_env import get_config
1517

18+
19+
@dataclass
20+
class Column:
21+
database: str
22+
table: str
23+
name: str
24+
column_type: str
25+
default_kind: Optional[str]
26+
default_expression: Optional[str]
27+
comment: Optional[str]
28+
29+
30+
@dataclass
31+
class Table:
32+
database: str
33+
name: str
34+
engine: str
35+
create_table_query: str
36+
dependencies_database: str
37+
dependencies_table: str
38+
engine_full: str
39+
sorting_key: str
40+
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
47+
comment: Optional[str] = None
48+
columns: List[Column] = field(default_factory=list)
49+
50+
1651
MCP_SERVER_NAME = "mcp-clickhouse"
1752

1853
# Configure logging
@@ -43,6 +78,24 @@
4378
)
4479

4580

81+
def result_to_table(query_columns, result) -> List[Table]:
82+
return [Table(**dict(zip(query_columns, row))) for row in result]
83+
84+
85+
def result_to_column(query_columns, result) -> List[Column]:
86+
return [Column(**dict(zip(query_columns, row))) for row in result]
87+
88+
89+
def to_json(obj: Any) -> str:
90+
if is_dataclass(obj):
91+
return json.dumps(asdict(obj), default=to_json)
92+
elif isinstance(obj, list):
93+
return [to_json(item) for item in obj]
94+
elif isinstance(obj, dict):
95+
return {key: to_json(value) for key, value in obj.items()}
96+
return obj
97+
98+
4699
@mcp.tool()
47100
def list_databases():
48101
"""List available ClickHouse databases"""
@@ -54,85 +107,38 @@ def list_databases():
54107

55108

56109
@mcp.tool()
57-
def list_tables(database: str, like: str = None):
110+
def list_tables(
111+
database: str, like: Optional[str] = None, not_like: Optional[str] = None
112+
):
58113
"""List available ClickHouse tables in a database, including schema, comment,
59114
row count, and column count."""
60115
logger.info(f"Listing tables in database '{database}'")
61116
client = create_clickhouse_client()
62-
query = f"SHOW TABLES FROM {quote_identifier(database)}"
117+
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)}"
63118
if like:
64-
query += f" LIKE {format_query_value(like)}"
65-
result = client.command(query)
119+
query += f" AND name LIKE {format_query_value(like)}"
66120

67-
# Get all table comments in one query
68-
table_comments_query = (
69-
f"SELECT name, comment FROM system.tables WHERE database = {format_query_value(database)}"
70-
)
71-
table_comments_result = client.query(table_comments_query)
72-
table_comments = {row[0]: row[1] for row in table_comments_result.result_rows}
73-
74-
# Get all column comments in one query
75-
column_comments_query = f"SELECT table, name, comment FROM system.columns WHERE database = {format_query_value(database)}"
76-
column_comments_result = client.query(column_comments_query)
77-
column_comments = {}
78-
for row in column_comments_result.result_rows:
79-
table, col_name, comment = row
80-
if table not in column_comments:
81-
column_comments[table] = {}
82-
column_comments[table][col_name] = comment
83-
84-
def get_table_info(table):
85-
logger.info(f"Getting schema info for table {database}.{table}")
86-
schema_query = f"DESCRIBE TABLE {quote_identifier(database)}.{quote_identifier(table)}"
87-
schema_result = client.query(schema_query)
88-
89-
columns = []
90-
column_names = schema_result.column_names
91-
for row in schema_result.result_rows:
92-
column_dict = {}
93-
for i, col_name in enumerate(column_names):
94-
column_dict[col_name] = row[i]
95-
# Add comment from our pre-fetched comments
96-
if table in column_comments and column_dict["name"] in column_comments[table]:
97-
column_dict["comment"] = column_comments[table][column_dict["name"]]
98-
else:
99-
column_dict["comment"] = None
100-
columns.append(column_dict)
101-
102-
# Get row count and column count from the table
103-
row_count_query = (
104-
f"SELECT count() FROM {quote_identifier(database)}.{quote_identifier(table)}"
105-
)
106-
row_count_result = client.query(row_count_query)
107-
row_count = row_count_result.result_rows[0][0] if row_count_result.result_rows else 0
108-
column_count = len(columns)
109-
110-
create_table_query = f"SHOW CREATE TABLE {database}.`{table}`"
111-
create_table_result = client.command(create_table_query)
112-
113-
return {
114-
"database": database,
115-
"name": table,
116-
"comment": table_comments.get(table),
117-
"columns": columns,
118-
"create_table_query": create_table_result,
119-
"row_count": row_count,
120-
"column_count": column_count,
121-
}
122-
123-
tables = []
124-
if isinstance(result, str):
125-
# Single table result
126-
for table in (t.strip() for t in result.split()):
127-
if table:
128-
tables.append(get_table_info(table))
129-
elif isinstance(result, Sequence):
130-
# Multiple table results
131-
for table in result:
132-
tables.append(get_table_info(table))
121+
if not_like:
122+
query += f" AND name NOT LIKE {format_query_value(not_like)}"
123+
124+
result = client.query(query)
125+
126+
# Deserialize result as Table dataclass instances
127+
tables = result_to_table(result.column_names, result.result_rows)
128+
129+
for table in tables:
130+
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)}"
131+
column_data_query_result = client.query(column_data_query)
132+
table.columns = [
133+
c
134+
for c in result_to_column(
135+
column_data_query_result.column_names,
136+
column_data_query_result.result_rows,
137+
)
138+
]
133139

134140
logger.info(f"Found {len(tables)} tables")
135-
return tables
141+
return [asdict(table) for table in tables]
136142

137143

138144
def execute_query(query: str):
@@ -169,10 +175,15 @@ def run_select_query(query: str):
169175
logger.warning(f"Query failed: {result['error']}")
170176
# MCP requires structured responses; string error messages can cause
171177
# serialization issues leading to BrokenResourceError
172-
return {"status": "error", "message": f"Query failed: {result['error']}"}
178+
return {
179+
"status": "error",
180+
"message": f"Query failed: {result['error']}",
181+
}
173182
return result
174183
except concurrent.futures.TimeoutError:
175-
logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
184+
logger.warning(
185+
f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}"
186+
)
176187
future.cancel()
177188
# Return a properly structured response for timeout errors
178189
return {

0 commit comments

Comments
 (0)