Skip to content

Commit 9aa8188

Browse files
authored
Allow specifying bind host and bind port (#64)
1 parent c0af32c commit 9aa8188

File tree

8 files changed

+151
-44
lines changed

8 files changed

+151
-44
lines changed

README.md

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ An MCP server for ClickHouse.
2929
* Input: `sql` (string): The SQL query to execute.
3030
* Query data directly from various sources (files, URLs, databases) without ETL processes.
3131

32+
### Health Check Endpoint
33+
34+
When running with HTTP or SSE transport, a health check endpoint is available at `/health`. This endpoint:
35+
- Returns `200 OK` with the ClickHouse version if the server is healthy and can connect to ClickHouse
36+
- Returns `503 Service Unavailable` if the server cannot connect to ClickHouse
37+
38+
Example:
39+
```bash
40+
curl http://localhost:8000/health
41+
# Response: OK - Connected to ClickHouse 24.3.1
42+
```
43+
3244
## Configuration
3345

3446
This MCP server supports both ClickHouse and chDB. You can enable either or both depending on your needs.
@@ -179,6 +191,18 @@ CLICKHOUSE_PASSWORD=clickhouse
179191

180192
4. For easy testing with the MCP Inspector, run `fastmcp dev mcp_clickhouse/mcp_server.py` to start the MCP server.
181193

194+
5. To test with HTTP transport and the health check endpoint:
195+
```bash
196+
# Using default port 8000
197+
CLICKHOUSE_MCP_SERVER_TRANSPORT=http python -m mcp_clickhouse.main
198+
199+
# Or with a custom port
200+
CLICKHOUSE_MCP_SERVER_TRANSPORT=http CLICKHOUSE_MCP_BIND_PORT=4200 python -m mcp_clickhouse.main
201+
202+
# Then in another terminal:
203+
curl http://localhost:8000/health # or http://localhost:4200/health for custom port
204+
```
205+
182206
### Environment Variables
183207

184208
The following environment variables are used to configure the ClickHouse and chDB connections:
@@ -216,7 +240,14 @@ The following environment variables are used to configure the ClickHouse and chD
216240
* Set this to automatically connect to a specific database
217241
* `CLICKHOUSE_MCP_SERVER_TRANSPORT`: Sets the transport method for the MCP server.
218242
* Default: `"stdio"`
219-
* Valid options: `"stdio"`, `"http"`, `"streamable-http"`, `"sse"`. This is useful for local development with tools like MCP Inspector.
243+
* Valid options: `"stdio"`, `"http"`, `"sse"`. This is useful for local development with tools like MCP Inspector.
244+
* `CLICKHOUSE_MCP_BIND_HOST`: Host to bind the MCP server to when using HTTP or SSE transport
245+
* Default: `"127.0.0.1"`
246+
* Set to `"0.0.0.0"` to bind to all network interfaces (useful for Docker or remote access)
247+
* Only used when transport is `"http"` or `"sse"`
248+
* `CLICKHOUSE_MCP_BIND_PORT`: Port to bind the MCP server to when using HTTP or SSE transport
249+
* Default: `"8000"`
250+
* Only used when transport is `"http"` or `"sse"`
220251
* `CLICKHOUSE_ENABLED`: Enable/disable ClickHouse functionality
221252
* Default: `"true"`
222253
* Set to `"false"` to disable ClickHouse tools when using chDB only
@@ -227,7 +258,7 @@ The following environment variables are used to configure the ClickHouse and chD
227258
* Default: `"false"`
228259
* Set to `"true"` to enable chDB tools
229260
* `CHDB_DATA_PATH`: The path to the chDB data directory
230-
* Default: `":memory:"` (in-memory database)
261+
* Default: `":memory:"` (in-memory database)
231262
* Use `:memory:` for in-memory database
232263
* Use a file path for persistent storage (e.g., `/path/to/chdb/data`)
233264

@@ -286,6 +317,21 @@ CLICKHOUSE_ENABLED=false
286317
CHDB_DATA_PATH=/path/to/chdb/data
287318
```
288319

320+
For MCP Inspector or remote access with HTTP transport:
321+
322+
```env
323+
CLICKHOUSE_HOST=localhost
324+
CLICKHOUSE_USER=default
325+
CLICKHOUSE_PASSWORD=clickhouse
326+
CLICKHOUSE_MCP_SERVER_TRANSPORT=http
327+
CLICKHOUSE_MCP_BIND_HOST=0.0.0.0 # Bind to all interfaces
328+
CLICKHOUSE_MCP_BIND_PORT=4200 # Custom port (default: 8000)
329+
```
330+
331+
When using HTTP transport, the server will run on the configured port (default 8000). For example, with the above configuration:
332+
- MCP endpoint: `http://localhost:4200/mcp`
333+
- Health check: `http://localhost:4200/health`
334+
289335
You can set these variables in your environment, in a `.env` file, or in the Claude Desktop configuration:
290336

291337
```json
@@ -305,13 +351,18 @@ You can set these variables in your environment, in a `.env` file, or in the Cla
305351
"CLICKHOUSE_HOST": "<clickhouse-host>",
306352
"CLICKHOUSE_USER": "<clickhouse-user>",
307353
"CLICKHOUSE_PASSWORD": "<clickhouse-password>",
308-
"CLICKHOUSE_DATABASE": "<optional-database>"
354+
"CLICKHOUSE_DATABASE": "<optional-database>",
355+
"CLICKHOUSE_MCP_SERVER_TRANSPORT": "stdio",
356+
"CLICKHOUSE_MCP_BIND_HOST": "127.0.0.1",
357+
"CLICKHOUSE_MCP_BIND_PORT": "8000"
309358
}
310359
}
311360
}
312361
}
313362
```
314363

364+
Note: The bind host and port settings are only used when transport is set to "http" or "sse".
365+
315366
### Running tests
316367

317368
```bash

mcp_clickhouse/main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
from .mcp_server import mcp
2-
from .mcp_env import get_config
2+
from .mcp_env import get_config, TransportType
33

44

55
def main():
66
config = get_config()
77
transport = config.mcp_server_transport
8-
mcp.run(transport=transport)
8+
9+
# For HTTP and SSE transports, we need to specify host and port
10+
http_transports = [TransportType.HTTP.value, TransportType.SSE.value]
11+
if transport in http_transports:
12+
# Use the configured bind host (defaults to 127.0.0.1, can be set to 0.0.0.0)
13+
# and bind port (defaults to 8000)
14+
mcp.run(transport=transport, host=config.mcp_bind_host, port=config.mcp_bind_port)
15+
else:
16+
# For stdio transport, no host or port is needed
17+
mcp.run(transport=transport)
918

1019

1120
if __name__ == "__main__":

mcp_clickhouse/mcp_env.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@
77
from dataclasses import dataclass
88
import os
99
from typing import Optional
10+
from enum import Enum
11+
12+
13+
class TransportType(str, Enum):
14+
"""Supported MCP server transport types."""
15+
16+
STDIO = "stdio"
17+
HTTP = "http"
18+
SSE = "sse"
19+
20+
@classmethod
21+
def values(cls) -> list[str]:
22+
"""Get all valid transport values."""
23+
return [transport.value for transport in cls]
1024

1125

1226
@dataclass
@@ -30,6 +44,8 @@ class ClickHouseConfig:
3044
CLICKHOUSE_DATABASE: Default database to use (default: None)
3145
CLICKHOUSE_PROXY_PATH: Path to be added to the host URL. For instance, for servers behind an HTTP proxy (default: None)
3246
CLICKHOUSE_MCP_SERVER_TRANSPORT: MCP server transport method - "stdio", "http", or "sse" (default: stdio)
47+
CLICKHOUSE_MCP_BIND_HOST: Host to bind the MCP server to when using HTTP or SSE transport (default: 127.0.0.1)
48+
CLICKHOUSE_MCP_BIND_PORT: Port to bind the MCP server to when using HTTP or SSE transport (default: 8000)
3349
CLICKHOUSE_ENABLED: Enable ClickHouse server (default: true)
3450
"""
3551

@@ -120,14 +136,32 @@ def mcp_server_transport(self) -> str:
120136
Valid options: "stdio", "http", "sse"
121137
Default: "stdio"
122138
"""
123-
transport = os.getenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "stdio").lower()
124-
valid_transports = "stdio", "http", "streamable-http", "sse"
125-
if transport not in valid_transports:
126-
raise ValueError(
127-
f"Invalid transport '{transport}'. Valid options: {', '.join(valid_transports)}"
128-
)
139+
transport = os.getenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", TransportType.STDIO.value).lower()
140+
141+
# Validate transport type
142+
if transport not in TransportType.values():
143+
valid_options = ", ".join(f'"{t}"' for t in TransportType.values())
144+
raise ValueError(f"Invalid transport '{transport}'. Valid options: {valid_options}")
129145
return transport
130146

147+
@property
148+
def mcp_bind_host(self) -> str:
149+
"""Get the host to bind the MCP server to.
150+
151+
Only used when transport is "http" or "sse".
152+
Default: "127.0.0.1"
153+
"""
154+
return os.getenv("CLICKHOUSE_MCP_BIND_HOST", "127.0.0.1")
155+
156+
@property
157+
def mcp_bind_port(self) -> int:
158+
"""Get the port to bind the MCP server to.
159+
160+
Only used when transport is "http" or "sse".
161+
Default: 8000
162+
"""
163+
return int(os.getenv("CLICKHOUSE_MCP_BIND_PORT", "8000"))
164+
131165
def get_client_config(self) -> dict:
132166
"""Get the configuration dictionary for clickhouse_connect client.
133167
@@ -239,7 +273,7 @@ def get_chdb_config() -> ChDBConfig:
239273
"""
240274
Gets the singleton instance of ChDBConfig.
241275
Instantiates it on the first call.
242-
276+
243277
Returns:
244278
ChDBConfig: The chDB configuration instance
245279
"""

mcp_clickhouse/mcp_server.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from fastmcp import FastMCP
1313
from fastmcp.tools import Tool
1414
from fastmcp.prompts import Prompt
15+
from fastmcp.exceptions import ToolError
1516
from dataclasses import dataclass, field, asdict, is_dataclass
17+
from starlette.requests import Request
18+
from starlette.responses import PlainTextResponse
1619

1720
from mcp_clickhouse.mcp_env import get_config, get_chdb_config
1821
from mcp_clickhouse.chdb_prompt import CHDB_PROMPT
@@ -75,6 +78,22 @@ class Table:
7578
)
7679

7780

81+
@mcp.custom_route("/health", methods=["GET"])
82+
async def health_check(request: Request) -> PlainTextResponse:
83+
"""Health check endpoint for monitoring server status.
84+
85+
Returns OK if the server is running and can connect to ClickHouse.
86+
"""
87+
try:
88+
# Try to create a client connection to verify ClickHouse connectivity
89+
client = create_clickhouse_client()
90+
version = client.server_version
91+
return PlainTextResponse(f"OK - Connected to ClickHouse {version}")
92+
except Exception as e:
93+
# Return 503 Service Unavailable if we can't connect to ClickHouse
94+
return PlainTextResponse(f"ERROR - Cannot connect to ClickHouse: {str(e)}", status_code=503)
95+
96+
7897
def result_to_table(query_columns, result) -> List[Table]:
7998
return [Table(**dict(zip(query_columns, row))) for row in result]
8099

@@ -150,9 +169,7 @@ def execute_query(query: str):
150169
return {"columns": res.column_names, "rows": res.result_rows}
151170
except Exception as err:
152171
logger.error(f"Error executing query: {err}")
153-
# Return a structured dictionary rather than a string to ensure proper serialization
154-
# by the MCP protocol. String responses for errors can cause BrokenResourceError.
155-
return {"error": str(err)}
172+
raise ToolError(f"Query execution failed: {str(err)}")
156173

157174

158175
def run_select_query(query: str):
@@ -175,16 +192,12 @@ def run_select_query(query: str):
175192
except concurrent.futures.TimeoutError:
176193
logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
177194
future.cancel()
178-
# Return a properly structured response for timeout errors
179-
return {
180-
"status": "error",
181-
"message": f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds",
182-
}
195+
raise ToolError(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds")
196+
except ToolError:
197+
raise
183198
except Exception as e:
184199
logger.error(f"Unexpected error in run_select_query: {str(e)}")
185-
# Catch all other exceptions and return them in a structured format
186-
# to prevent MCP serialization failures
187-
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
200+
raise RuntimeError(f"Unexpected error during query execution: {str(e)}")
188201

189202

190203
def create_clickhouse_client():
@@ -249,7 +262,7 @@ def execute_chdb_query(query: str):
249262
"""Execute a query using chDB client."""
250263
client = create_chdb_client()
251264
try:
252-
res = client.query(query, 'JSON')
265+
res = client.query(query, "JSON")
253266
if res.has_error():
254267
error_msg = res.error_message()
255268
logger.error(f"Error executing chDB query: {error_msg}")
@@ -310,7 +323,7 @@ def _init_chdb_client():
310323
return None
311324

312325
client_config = get_chdb_config().get_client_config()
313-
data_path = client_config['data_path']
326+
data_path = client_config["data_path"]
314327
logger.info(f"Creating chDB client with data_path={data_path}")
315328
client = chs.Session(path=data_path)
316329
logger.info(f"Successfully connected to chDB with data_path={data_path}")
@@ -337,7 +350,7 @@ def _init_chdb_client():
337350
chdb_prompt = Prompt.from_function(
338351
chdb_initial_prompt,
339352
name="chdb_initial_prompt",
340-
description="This prompt helps users understand how to interact and perform common operations in chDB"
353+
description="This prompt helps users understand how to interact and perform common operations in chDB",
341354
)
342355
mcp.add_prompt(chdb_prompt)
343356
logger.info("chDB tools and prompts registered")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-clickhouse"
3-
version = "0.1.9"
3+
version = "0.1.10"
44
description = "An MCP server for ClickHouse."
55
readme = "README.md"
66
license = "Apache-2.0"

tests/test_mcp_server.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import pytest_asyncio
33
from fastmcp import Client
4+
from fastmcp.exceptions import ToolError
45
import asyncio
56
from mcp_clickhouse.mcp_server import mcp, create_clickhouse_client
67
from dotenv import load_dotenv
@@ -256,15 +257,12 @@ async def test_run_select_query_error(mcp_server, setup_test_database):
256257
async with Client(mcp_server) as client:
257258
# Query non-existent table
258259
query = f"SELECT * FROM {test_db}.non_existent_table"
259-
result = await client.call_tool("run_select_query", {"query": query})
260260

261-
query_result = json.loads(result[0].text)
261+
# Should raise ToolError
262+
with pytest.raises(ToolError) as exc_info:
263+
await client.call_tool("run_select_query", {"query": query})
262264

263-
# Should return error structure
264-
assert "status" in query_result
265-
assert query_result["status"] == "error"
266-
assert "message" in query_result
267-
assert "Query failed" in query_result["message"]
265+
assert "Query execution failed" in str(exc_info.value)
268266

269267

270268
@pytest.mark.asyncio
@@ -273,13 +271,12 @@ async def test_run_select_query_syntax_error(mcp_server):
273271
async with Client(mcp_server) as client:
274272
# Invalid SQL syntax
275273
query = "SELECT FROM WHERE"
276-
result = await client.call_tool("run_select_query", {"query": query})
277274

278-
query_result = json.loads(result[0].text)
275+
# Should raise ToolError
276+
with pytest.raises(ToolError) as exc_info:
277+
await client.call_tool("run_select_query", {"query": query})
279278

280-
# Should return error structure
281-
assert query_result["status"] == "error"
282-
assert "Query failed" in query_result["message"]
279+
assert "Query execution failed" in str(exc_info.value)
283280

284281

285282
@pytest.mark.asyncio

tests/test_tool.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33

44
from dotenv import load_dotenv
5+
from fastmcp.exceptions import ToolError
56

67
from mcp_clickhouse import create_clickhouse_client, list_databases, list_tables, run_select_query
78

@@ -73,10 +74,12 @@ def test_run_select_query_success(self):
7374
def test_run_select_query_failure(self):
7475
"""Test running a SELECT query with an error."""
7576
query = f"SELECT * FROM {self.test_db}.non_existent_table"
76-
result = run_select_query(query)
77-
self.assertIsInstance(result, dict)
78-
self.assertEqual(result["status"], "error")
79-
self.assertIn("Query failed", result["message"])
77+
78+
# Should raise ToolError
79+
with self.assertRaises(ToolError) as context:
80+
run_select_query(query)
81+
82+
self.assertIn("Query execution failed", str(context.exception))
8083

8184
def test_table_and_column_comments(self):
8285
"""Test that table and column comments are correctly retrieved."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)