Skip to content

Commit 49f5746

Browse files
iskakaushikemanb29
andcommitted
Allow specifying bind host and bind port (ClickHouse#64)
Co-authored-by: Ethan Bell <ebell@hydrolix.io>
1 parent 33d41a6 commit 49f5746

File tree

6 files changed

+130
-36
lines changed

6 files changed

+130
-36
lines changed

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ Due to the wide variety in LLM architectures, not all models will proactively us
2727
* Include time ranges in your prompts (e.g., "Between December 5 2023 and January 18 2024, ...") and specifically request that the output be ordered by timestamp.
2828
- This prompts the model to write more efficient queries that take advantage of [primary key optimizations](https://hydrolix.io/blog/optimizing-latest-n-row-queries/)
2929

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

3244
The Hydrolix MCP server is configured using a standard MCP server entry. Consult your client's documentation for specific instructions on where to find or declare MCP servers. An example setup using Claude Desktop is documented below.
@@ -129,4 +141,29 @@ The following variables are used to configure the Hydrolix connection. These var
129141
* Set this to automatically connect to a specific database
130142
* `HYDROLIX_MCP_SERVER_TRANSPORT`: Sets the transport method for the MCP server.
131143
* Default: `"stdio"`
132-
* Valid options: `"stdio"`, `"http"`, `"streamable-http"`, `"sse"`. This is useful for local development with tools like MCP Inspector.
144+
* Valid options: `"stdio"`, `"http"`, `"sse"`. This is useful for local development with tools like MCP Inspector.
145+
* `HYDROLIX_MCP_BIND_HOST`: Host to bind the MCP server to when using HTTP or SSE transport
146+
* Default: `"127.0.0.1"`
147+
* Set to `"0.0.0.0"` to bind to all network interfaces (useful for Docker or remote access)
148+
* Only used when transport is `"http"` or `"sse"`
149+
* `HYDROLIX_MCP_BIND_PORT`: Port to bind the MCP server to when using HTTP or SSE transport
150+
* Default: `"8000"`
151+
* Only used when transport is `"http"` or `"sse"`
152+
153+
154+
For MCP Inspector or remote access with HTTP transport:
155+
156+
```env
157+
HYDROLIX_HOST=localhost
158+
HYDROLIX_USER=default
159+
HYDROLIX_PASSWORD=myPassword
160+
HYDROLIX_MCP_SERVER_TRANSPORT=http
161+
HYDROLIX_MCP_BIND_HOST=0.0.0.0 # Bind to all interfaces
162+
HYDROLIX_MCP_BIND_PORT=4200 # Custom port (default: 8000)
163+
```
164+
165+
When using HTTP transport, the server will run on the configured port (default 8000). For example, with the above configuration:
166+
- MCP endpoint: `http://localhost:4200/mcp`
167+
- Health check: `http://localhost:4200/health`
168+
169+
Note: The bind host and port settings are only used when transport is set to "http" or "sse".

mcp_hydrolix/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_hydrolix/mcp_env.py

Lines changed: 41 additions & 6 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
@@ -29,6 +43,8 @@ class HydrolixConfig:
2943
HYDROLIX_DATABASE: Default database to use (default: None)
3044
HYDROLIX_PROXY_PATH: Path to be added to the host URL. For instance, for servers behind an HTTP proxy (default: None)
3145
HYDROLIX_MCP_SERVER_TRANSPORT: MCP server transport method - "stdio", "http", or "sse" (default: stdio)
46+
HYDROLIX_MCP_BIND_HOST: Host to bind the MCP server to when using HTTP or SSE transport (default: 127.0.0.1)
47+
HYDROLIX_MCP_BIND_PORT: Port to bind the MCP server to when using HTTP or SSE transport (default: 8000)
3248
"""
3349

3450
def __init__(self):
@@ -89,6 +105,7 @@ def send_receive_timeout(self) -> int:
89105
Default: 300 (Hydrolix default)
90106
"""
91107
return int(os.getenv("HYDROLIX_SEND_RECEIVE_TIMEOUT", "300"))
108+
92109
@property
93110
def proxy_path(self) -> str:
94111
return os.getenv("HYDROLIX_PROXY_PATH")
@@ -100,14 +117,32 @@ def mcp_server_transport(self) -> str:
100117
Valid options: "stdio", "http", "sse"
101118
Default: "stdio"
102119
"""
103-
transport = os.getenv("HYDROLIX_MCP_SERVER_TRANSPORT", "stdio").lower()
104-
valid_transports = "stdio", "http", "streamable-http", "sse"
105-
if transport not in valid_transports:
106-
raise ValueError(
107-
f"Invalid transport '{transport}'. Valid options: {', '.join(valid_transports)}"
108-
)
120+
transport = os.getenv("HYDROLIX_MCP_SERVER_TRANSPORT", TransportType.STDIO.value).lower()
121+
122+
# Validate transport type
123+
if transport not in TransportType.values():
124+
valid_options = ", ".join(f'"{t}"' for t in TransportType.values())
125+
raise ValueError(f"Invalid transport '{transport}'. Valid options: {valid_options}")
109126
return transport
110127

128+
@property
129+
def mcp_bind_host(self) -> str:
130+
"""Get the host to bind the MCP server to.
131+
132+
Only used when transport is "http" or "sse".
133+
Default: "127.0.0.1"
134+
"""
135+
return os.getenv("HYDROLIX_MCP_BIND_HOST", "127.0.0.1")
136+
137+
@property
138+
def mcp_bind_port(self) -> int:
139+
"""Get the port to bind the MCP server to.
140+
141+
Only used when transport is "http" or "sse".
142+
Default: 8000
143+
"""
144+
return int(os.getenv("HYDROLIX_MCP_BIND_PORT", "8000"))
145+
111146
def get_client_config(self) -> dict:
112147
"""Get the configuration dictionary for clickhouse_connect client.
113148

mcp_hydrolix/mcp_server.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from clickhouse_connect.driver.binding import format_query_value
99
from dotenv import load_dotenv
1010
from fastmcp import FastMCP
11+
from fastmcp.exceptions import ToolError
1112
from dataclasses import dataclass, field, asdict, is_dataclass
13+
from starlette.requests import Request
14+
from starlette.responses import PlainTextResponse
1215

1316
from mcp_hydrolix.mcp_env import get_config
1417

@@ -69,6 +72,22 @@ class Table:
6972
)
7073

7174

75+
@mcp.custom_route("/health", methods=["GET"])
76+
async def health_check(request: Request) -> PlainTextResponse:
77+
"""Health check endpoint for monitoring server status.
78+
79+
Returns OK if the server is running and can connect to Hydrolix.
80+
"""
81+
try:
82+
# Try to create a client connection to verify query-head connectivity
83+
client = create_hydrolix_client()
84+
version = client.server_version
85+
return PlainTextResponse(f"OK - Connected to Hydrolix compatible with ClickHouse {version}")
86+
except Exception as e:
87+
# Return 503 Service Unavailable if we can't connect to Hydrolix
88+
return PlainTextResponse(f"ERROR - Cannot connect to Hydrolix: {str(e)}", status_code=503)
89+
90+
7291
def result_to_table(query_columns, result) -> List[Table]:
7392
return [Table(**dict(zip(query_columns, row))) for row in result]
7493

@@ -155,9 +174,7 @@ def execute_query(query: str):
155174
return {"columns": res.column_names, "rows": res.result_rows}
156175
except Exception as err:
157176
logger.error(f"Error executing query: {err}")
158-
# Return a structured dictionary rather than a string to ensure proper serialization
159-
# by the MCP protocol. String responses for errors can cause BrokenResourceError.
160-
return {"error": str(err)}
177+
raise ToolError(f"Query execution failed: {str(err)}")
161178

162179

163180
@mcp.tool()
@@ -217,16 +234,12 @@ def run_select_query(query: str):
217234
except concurrent.futures.TimeoutError:
218235
logger.warning(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds: {query}")
219236
future.cancel()
220-
# Return a properly structured response for timeout errors
221-
return {
222-
"status": "error",
223-
"message": f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds",
224-
}
237+
raise ToolError(f"Query timed out after {SELECT_QUERY_TIMEOUT_SECS} seconds")
238+
except ToolError:
239+
raise
225240
except Exception as e:
226241
logger.error(f"Unexpected error in run_select_query: {str(e)}")
227-
# Catch all other exceptions and return them in a structured format
228-
# to prevent MCP serialization failures
229-
return {"status": "error", "message": f"Unexpected error: {str(e)}"}
242+
raise RuntimeError(f"Unexpected error during query execution: {str(e)}")
230243

231244

232245
def create_hydrolix_client():

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_hydrolix import create_hydrolix_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.fn(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.fn(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."""

0 commit comments

Comments
 (0)