Skip to content

Add support for multiple ClickHouse configurations #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .mcp_clickhouse_env
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# MCP Server Configuration
export MCP_SERVER_PORT=3000 # Set port to 3000
export MCP_SERVER_HOST=0.0.0.0 # Allow connections from all network interfaces

# Define list of ClickHouse servers to connect to
export CLICKHOUSE_SERVERS=prod,test,dev

# Default ClickHouse connection
export CLICKHOUSE_HOST=default-clickhouse-host.example.com
export CLICKHOUSE_USER=default_user
export CLICKHOUSE_PASSWORD=default_password
export CLICKHOUSE_DATABASE=default_db
export CLICKHOUSE_PORT=8443
export CLICKHOUSE_SECURE=true

# Production ClickHouse
export CLICKHOUSE_PROD_HOST=prod-clickhouse.example.com
export CLICKHOUSE_PROD_USER=prod_user
export CLICKHOUSE_PROD_PASSWORD=prod_password
export CLICKHOUSE_PROD_DATABASE=analytics
export CLICKHOUSE_PROD_PORT=8443
export CLICKHOUSE_PROD_SECURE=true

# Test ClickHouse
export CLICKHOUSE_TEST_HOST=test-clickhouse.example.com
export CLICKHOUSE_TEST_USER=test_user
export CLICKHOUSE_TEST_PASSWORD=test_password
export CLICKHOUSE_TEST_DATABASE=test_db
export CLICKHOUSE_TEST_PORT=8443
export CLICKHOUSE_TEST_SECURE=true

# Development ClickHouse
export CLICKHOUSE_DEV_HOST=dev-clickhouse.example.com
export CLICKHOUSE_DEV_USER=dev_user
export CLICKHOUSE_DEV_PASSWORD=dev_password
export CLICKHOUSE_DEV_DATABASE=dev_db
export CLICKHOUSE_DEV_PORT=8123
export CLICKHOUSE_DEV_SECURE=false
6 changes: 6 additions & 0 deletions mcp_clickhouse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
list_databases,
list_tables,
run_select_query,
list_clickhouse_servers,
)
from .mcp_env import get_config, get_all_configs, get_mcp_server_config

__all__ = [
"list_databases",
"list_tables",
"run_select_query",
"create_clickhouse_client",
"list_clickhouse_servers",
"get_config",
"get_all_configs",
"get_mcp_server_config",
]
4 changes: 2 additions & 2 deletions mcp_clickhouse/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from .mcp_server import mcp
from .mcp_server import run_server


def main():
mcp.run()
run_server()


if __name__ == "__main__":
Expand Down
257 changes: 169 additions & 88 deletions mcp_clickhouse/mcp_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
and type conversion.
"""

from dataclasses import dataclass
from dataclasses import dataclass, field
import os
from typing import Optional
from typing import Optional, Dict, List


@dataclass
Expand All @@ -30,72 +30,49 @@ class ClickHouseConfig:
CLICKHOUSE_DATABASE: Default database to use (default: None)
"""

def __init__(self):
"""Initialize the configuration from environment variables."""
self._validate_required_vars()

@property
def host(self) -> str:
"""Get the ClickHouse host."""
return os.environ["CLICKHOUSE_HOST"]

@property
def port(self) -> int:
"""Get the ClickHouse port.

Defaults to 8443 if secure=True, 8123 if secure=False.
Can be overridden by CLICKHOUSE_PORT environment variable.
"""
if "CLICKHOUSE_PORT" in os.environ:
return int(os.environ["CLICKHOUSE_PORT"])
return 8443 if self.secure else 8123

@property
def username(self) -> str:
"""Get the ClickHouse username."""
return os.environ["CLICKHOUSE_USER"]

@property
def password(self) -> str:
"""Get the ClickHouse password."""
return os.environ["CLICKHOUSE_PASSWORD"]

@property
def database(self) -> Optional[str]:
"""Get the default database name if set."""
return os.getenv("CLICKHOUSE_DATABASE")

@property
def secure(self) -> bool:
"""Get whether HTTPS is enabled.

Default: True
"""
return os.getenv("CLICKHOUSE_SECURE", "true").lower() == "true"

@property
def verify(self) -> bool:
"""Get whether SSL certificate verification is enabled.

Default: True
"""
return os.getenv("CLICKHOUSE_VERIFY", "true").lower() == "true"

@property
def connect_timeout(self) -> int:
"""Get the connection timeout in seconds.

Default: 30
"""
return int(os.getenv("CLICKHOUSE_CONNECT_TIMEOUT", "30"))

@property
def send_receive_timeout(self) -> int:
"""Get the send/receive timeout in seconds.

Default: 300 (ClickHouse default)
name: str = "default" # Name identifier
host: str = None
port: Optional[int] = None
username: str = None
password: str = None
database: Optional[str] = None
secure: bool = True
verify: bool = True
connect_timeout: int = 30
send_receive_timeout: int = 300

def __init__(self, name: str = "default", env_prefix: str = "CLICKHOUSE"):
"""Initialize the configuration from environment variables.

Args:
name: Name identifier for this ClickHouse connection
env_prefix: Prefix for environment variables
"""
return int(os.getenv("CLICKHOUSE_SEND_RECEIVE_TIMEOUT", "300"))
self.name = name
prefix = f"{env_prefix}_" if name == "default" else f"{env_prefix}_{name.upper()}_"

# Set required parameters
self.host = os.environ.get(f"{prefix}HOST")
self.username = os.environ.get(f"{prefix}USER")
self.password = os.environ.get(f"{prefix}PASSWORD")

# Set optional parameters
port_env = os.environ.get(f"{prefix}PORT")
if port_env:
self.port = int(port_env)

self.database = os.environ.get(f"{prefix}DATABASE")
self.secure = os.environ.get(f"{prefix}SECURE", "true").lower() == "true"
self.verify = os.environ.get(f"{prefix}VERIFY", "true").lower() == "true"
self.connect_timeout = int(os.environ.get(f"{prefix}CONNECT_TIMEOUT", "30"))
self.send_receive_timeout = int(os.environ.get(f"{prefix}SEND_RECEIVE_TIMEOUT", "300"))

if not self.port:
self.port = 8443 if self.secure else 8123

def validate(self) -> bool:
"""Validate if the configuration is valid."""
return bool(self.host and self.username and self.password)

def get_client_config(self) -> dict:
"""Get the configuration dictionary for clickhouse_connect client.
Expand All @@ -112,7 +89,7 @@ def get_client_config(self) -> dict:
"verify": self.verify,
"connect_timeout": self.connect_timeout,
"send_receive_timeout": self.send_receive_timeout,
"client_name": "mcp_clickhouse",
"client_name": f"mcp_clickhouse_{self.name}",
}

# Add optional database if set
Expand All @@ -121,32 +98,136 @@ def get_client_config(self) -> dict:

return config

def _validate_required_vars(self) -> None:
"""Validate that all required environment variables are set.

Raises:
ValueError: If any required environment variable is missing.
@dataclass
class MultiClickHouseConfig:
"""Manage multiple ClickHouse connection configurations."""

configs: Dict[str, ClickHouseConfig] = field(default_factory=dict)
default_config_name: str = "default"

def __init__(self, allow_empty: bool = False):
"""Initialize all ClickHouse connection configurations from environment variables.

Args:
allow_empty: If True, don't raise an error when no valid configurations are found.
This is useful for testing.
"""
# Initialize configs dictionary
self.configs = {}
self.default_config_name = None

# First, look for additional servers defined by CLICKHOUSE_SERVERS
valid_additional_configs = False
servers_str = os.environ.get("CLICKHOUSE_SERVERS", "")
if servers_str:
server_names = [name.strip() for name in servers_str.split(",")]
for name in server_names:
if name and name != "default":
config = ClickHouseConfig(name=name)
if config.validate():
self.configs[name] = config
# Set the first valid additional config as default
if self.default_config_name is None:
self.default_config_name = name
valid_additional_configs = True

# Only try to load default configuration if no valid additional configs were found
# or if CLICKHOUSE_SERVERS is not defined
if not valid_additional_configs:
default_config = ClickHouseConfig(name="default")
if default_config.validate():
self.configs["default"] = default_config
self.default_config_name = "default"

if not self.configs and not allow_empty:
raise ValueError("No valid ClickHouse configuration found. Please configure at least one valid ClickHouse connection.")

def get_config(self, name: Optional[str] = None) -> ClickHouseConfig:
"""Get a configuration by name, or return the default if not specified or not found.

Args:
name: Server configuration name

Returns:
ClickHouse configuration object
"""
missing_vars = []
for var in ["CLICKHOUSE_HOST", "CLICKHOUSE_USER", "CLICKHOUSE_PASSWORD"]:
if var not in os.environ:
missing_vars.append(var)
if name and name in self.configs:
return self.configs[name]

if self.default_config_name and self.default_config_name in self.configs:
return self.configs[self.default_config_name]

raise ValueError("No valid ClickHouse configuration found.")

def get_available_servers(self) -> List[str]:
"""Get a list of all available ClickHouse server names.

Returns:
List of server names
"""
return list(self.configs.keys())

if missing_vars:
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")

@dataclass
class MCPServerConfig:
"""MCP server configuration."""

port: int = 8080
host: str = "0.0.0.0"

def __init__(self):
"""Initialize MCP server configuration from environment variables."""
if "MCP_SERVER_PORT" in os.environ:
self.port = int(os.environ["MCP_SERVER_PORT"])
if "MCP_SERVER_HOST" in os.environ:
self.host = os.environ["MCP_SERVER_HOST"]


# Global instance placeholder for the singleton pattern
_CONFIG_INSTANCE = None
# Global singletons
_MULTI_CONFIG_INSTANCE = None
_MCP_SERVER_CONFIG = None


def get_config():
def get_config(name: Optional[str] = None, allow_empty: bool = False) -> ClickHouseConfig:
"""
Get a ClickHouse configuration instance.

Args:
name: Optional configuration name, uses default if not specified
allow_empty: If True, don't raise an error when no valid configurations are found

Returns:
ClickHouse configuration instance with the specified name
"""
Gets the singleton instance of ClickHouseConfig.
Instantiates it on the first call.
global _MULTI_CONFIG_INSTANCE
if _MULTI_CONFIG_INSTANCE is None:
_MULTI_CONFIG_INSTANCE = MultiClickHouseConfig(allow_empty=allow_empty)
return _MULTI_CONFIG_INSTANCE.get_config(name)


def get_all_configs(allow_empty: bool = False) -> MultiClickHouseConfig:
"""Get the multi-ClickHouse configuration management instance.

Args:
allow_empty: If True, don't raise an error when no valid configurations are found

Returns:
MultiClickHouseConfig instance
"""
global _MULTI_CONFIG_INSTANCE
if _MULTI_CONFIG_INSTANCE is None:
_MULTI_CONFIG_INSTANCE = MultiClickHouseConfig(allow_empty=allow_empty)
return _MULTI_CONFIG_INSTANCE


def get_mcp_server_config() -> MCPServerConfig:
"""Get the MCP server configuration instance.

Returns:
MCPServerConfig instance
"""
global _CONFIG_INSTANCE
if _CONFIG_INSTANCE is None:
# Instantiate the config object here, ensuring load_dotenv() has likely run
_CONFIG_INSTANCE = ClickHouseConfig()
return _CONFIG_INSTANCE
global _MCP_SERVER_CONFIG
if _MCP_SERVER_CONFIG is None:
_MCP_SERVER_CONFIG = MCPServerConfig()
return _MCP_SERVER_CONFIG
Loading