diff --git a/README.md b/README.md index aafd61b6..0df4b524 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,37 @@ Or, if you'd like to try it out with the [ClickHouse SQL Playground](https://sql } ``` +For mTLS (mutual TLS) authentication, where client certificates are used instead of a password: + +```json +{ + "mcpServers": { + "mcp-clickhouse": { + "command": "uv", + "args": [ + "run", + "--with", + "mcp-clickhouse", + "--python", + "3.10", + "mcp-clickhouse" + ], + "env": { + "CLICKHOUSE_HOST": "", + "CLICKHOUSE_PORT": "", + "CLICKHOUSE_USER": "", + "CLICKHOUSE_SECURE": "true", + "CLICKHOUSE_VERIFY": "true", + "CLICKHOUSE_CLIENT_CERT": "/path/to/client.pem", + "CLICKHOUSE_CLIENT_CERT_KEY": "/path/to/client.key", + "CLICKHOUSE_CONNECT_TIMEOUT": "30", + "CLICKHOUSE_SEND_RECEIVE_TIMEOUT": "30" + } + } + } +} +``` + For chDB (embedded ClickHouse engine), add the following configuration: ```json @@ -471,6 +502,7 @@ The following environment variables are used to configure the ClickHouse and chD * `CLICKHOUSE_HOST`: The hostname of your ClickHouse server * `CLICKHOUSE_USER`: The username for authentication * `CLICKHOUSE_PASSWORD`: The password for authentication + * Not required when using mTLS client certificate authentication (see `CLICKHOUSE_CLIENT_CERT`) > [!CAUTION] > 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. @@ -536,6 +568,13 @@ The following environment variables are used to configure the ClickHouse and chD * Only takes effect when `CLICKHOUSE_ALLOW_WRITE_ACCESS=true` is also set * Set to `"true"` to explicitly allow destructive DROP and TRUNCATE operations * This is a safety feature to prevent accidental data deletion during AI exploration +* `CLICKHOUSE_CLIENT_CERT`: Path to TLS client certificate for mTLS authentication + * Default: None + * When set, `CLICKHOUSE_PASSWORD` is no longer required + * Must be used with `CLICKHOUSE_CLIENT_CERT_KEY` +* `CLICKHOUSE_CLIENT_CERT_KEY`: Path to TLS client private key for mTLS authentication + * Default: None + * Must be used with `CLICKHOUSE_CLIENT_CERT` #### Middleware Variables @@ -611,6 +650,17 @@ CLICKHOUSE_ENABLED=false CHDB_DATA_PATH=/path/to/chdb/data ``` +For mTLS (mutual TLS) authentication: + +```env +CLICKHOUSE_HOST=your-instance.clickhouse.cloud +CLICKHOUSE_USER=default +CLICKHOUSE_SECURE=true +CLICKHOUSE_CLIENT_CERT=/path/to/client.pem +CLICKHOUSE_CLIENT_CERT_KEY=/path/to/client.key +# CLICKHOUSE_PASSWORD is not required when using client certificates +``` + For MCP Inspector or remote access with HTTP transport: ```env diff --git a/mcp_clickhouse/mcp_env.py b/mcp_clickhouse/mcp_env.py index 87668740..8c39f0ae 100644 --- a/mcp_clickhouse/mcp_env.py +++ b/mcp_clickhouse/mcp_env.py @@ -48,6 +48,8 @@ class ClickHouseConfig: CLICKHOUSE_ENABLED: Enable ClickHouse server (default: true) CLICKHOUSE_ALLOW_WRITE_ACCESS: Allow write operations (DDL and DML) (default: false) CLICKHOUSE_ALLOW_DROP: Allow destructive operations (DROP, TRUNCATE) when writes are also enabled (default: false) + CLICKHOUSE_CLIENT_CERT: Path to TLS client certificate for mTLS authentication (default: None) + CLICKHOUSE_CLIENT_CERT_KEY: Path to TLS client private key for mTLS authentication (default: None) """ def __init__(self): @@ -86,8 +88,11 @@ def username(self) -> str: @property def password(self) -> str: - """Get the ClickHouse password.""" - return os.environ["CLICKHOUSE_PASSWORD"] + """Get the ClickHouse password. + + Returns empty string when not set and client certificates are configured. + """ + return os.getenv("CLICKHOUSE_PASSWORD", "") @property def role(self) -> Optional[str]: @@ -159,6 +164,16 @@ def allow_drop(self) -> bool: """ return os.getenv("CLICKHOUSE_ALLOW_DROP", "false").lower() == "true" + @property + def client_cert(self) -> Optional[str]: + """Get the path to the TLS client certificate for mTLS authentication.""" + return os.getenv("CLICKHOUSE_CLIENT_CERT") + + @property + def client_cert_key(self) -> Optional[str]: + """Get the path to the TLS client private key for mTLS authentication.""" + return os.getenv("CLICKHOUSE_CLIENT_CERT_KEY") + def get_client_config(self) -> dict: """Get the configuration dictionary for clickhouse_connect client. @@ -191,6 +206,10 @@ def get_client_config(self) -> dict: if self.server_host_name: config["server_host_name"] = self.server_host_name + if self.client_cert: + config["client_cert"] = self.client_cert + if self.client_cert_key: + config["client_cert_key"] = self.client_cert_key return config @@ -201,10 +220,13 @@ def _validate_required_vars(self) -> None: ValueError: If any required environment variable is missing. """ missing_vars = [] - for var in ["CLICKHOUSE_HOST", "CLICKHOUSE_USER", "CLICKHOUSE_PASSWORD"]: + for var in ["CLICKHOUSE_HOST", "CLICKHOUSE_USER"]: if var not in os.environ: missing_vars.append(var) + if "CLICKHOUSE_PASSWORD" not in os.environ and not self.client_cert: + missing_vars.append("CLICKHOUSE_PASSWORD") + if missing_vars: raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") diff --git a/tests/test_config_interface.py b/tests/test_config_interface.py index 9275d388..59656bb3 100644 --- a/tests/test_config_interface.py +++ b/tests/test_config_interface.py @@ -120,3 +120,45 @@ def test_server_host_name_omitted_when_unset(monkeypatch: pytest.MonkeyPatch): client_config = config.get_client_config() assert "server_host_name" not in client_config + + +def test_client_cert_configuration(monkeypatch: pytest.MonkeyPatch): + """Test that client cert and key are passed through when set.""" + monkeypatch.setenv("CLICKHOUSE_HOST", "localhost") + monkeypatch.setenv("CLICKHOUSE_USER", "test") + monkeypatch.setenv("CLICKHOUSE_CLIENT_CERT", "/path/to/client.pem") + monkeypatch.setenv("CLICKHOUSE_CLIENT_CERT_KEY", "/path/to/client.key") + monkeypatch.delenv("CLICKHOUSE_PASSWORD", raising=False) + + config = ClickHouseConfig() + client_config = config.get_client_config() + + assert client_config["client_cert"] == "/path/to/client.pem" + assert client_config["client_cert_key"] == "/path/to/client.key" + assert client_config["password"] == "" + + +def test_client_cert_not_in_config_when_unset(monkeypatch: pytest.MonkeyPatch): + """Test that client cert fields are omitted when not set.""" + monkeypatch.setenv("CLICKHOUSE_HOST", "localhost") + monkeypatch.setenv("CLICKHOUSE_USER", "test") + monkeypatch.setenv("CLICKHOUSE_PASSWORD", "test") + monkeypatch.delenv("CLICKHOUSE_CLIENT_CERT", raising=False) + monkeypatch.delenv("CLICKHOUSE_CLIENT_CERT_KEY", raising=False) + + config = ClickHouseConfig() + client_config = config.get_client_config() + + assert "client_cert" not in client_config + assert "client_cert_key" not in client_config + + +def test_password_required_without_client_cert(monkeypatch: pytest.MonkeyPatch): + """Test that CLICKHOUSE_PASSWORD is required when no client cert is set.""" + monkeypatch.setenv("CLICKHOUSE_HOST", "localhost") + monkeypatch.setenv("CLICKHOUSE_USER", "test") + monkeypatch.delenv("CLICKHOUSE_PASSWORD", raising=False) + monkeypatch.delenv("CLICKHOUSE_CLIENT_CERT", raising=False) + + with pytest.raises(ValueError, match="CLICKHOUSE_PASSWORD"): + ClickHouseConfig()