Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## Unreleased

### Changed
- Connection failures now log actionable hints for common misconfigurations (native TCP port used instead of the HTTP interface, TLS/`CLICKHOUSE_SECURE` mismatches), and a warning is logged when `CLICKHOUSE_PORT` is set to a native protocol port (9000/9440). ([#102](https://github.com/ClickHouse/mcp-clickhouse/issues/102))

## 0.4.1 - 2026-07-17

### Changed
Expand Down
104 changes: 103 additions & 1 deletion mcp_clickhouse/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,99 @@ async def run_query_async(query: str) -> str:
raise RuntimeError(f"Unexpected error during query execution: {str(e)}")


# ClickHouse native TCP protocol ports (clickhouse-client). This MCP server uses the
# HTTP interface only (default 8123 / 8443). Connecting to native ports fails with
# messages like "Port 9000 is for clickhouse-client program".
_NATIVE_PROTOCOL_PORTS = frozenset({9000, 9440})


def _connection_error_hints(error: Exception, client_config: dict) -> List[str]:
"""Return actionable hints for common ClickHouse connection misconfigurations.

Helps users who confuse MCP transport settings with database settings, or who
point CLICKHOUSE_PORT at the native TCP protocol instead of the HTTP interface.
"""
hints: List[str] = []
err = str(error).lower()
port = client_config.get("port")
secure = bool(client_config.get("secure"))
host = client_config.get("host", "<unknown>")

native_response_port = next(
(
native_port
for native_port in _NATIVE_PROTOCOL_PORTS
if f"port {native_port} is for clickhouse-client" in err
),
None,
)
if port in _NATIVE_PROTOCOL_PORTS:
hints.append(
f"CLICKHOUSE_PORT={port} looks like ClickHouse's native TCP protocol port "
"(used by clickhouse-client). This server uses the HTTP interface — set "
"CLICKHOUSE_PORT to 8123 (HTTP) or 8443 (HTTPS), or your deployment's HTTP "
"mapping. Do not use native ports 9000/9440."
)
elif native_response_port is not None:
hints.append(
f"The ClickHouse response indicates that this request reached native TCP port "
f"{native_response_port}, even though the client was configured for {host}:{port}. "
"Check DNS, service, proxy, load-balancer, and port mappings to ensure traffic is "
"routed to ClickHouse's HTTP interface (8123/8443 by default, or your deployment's "
"HTTP mapping)."
)

tls_tokens = (
"ssl",
"tls",
"certificate",
"handshake",
"wrong version number",
"certificate verify failed",
"unexpected_eof",
"eof occurred in violation of protocol",
)
if any(token in err for token in tls_tokens):
scheme = "HTTPS" if secure else "HTTP"
hints.append(
f"TLS/SSL error while connecting with CLICKHOUSE_SECURE="
f"{str(secure).lower()} ({scheme} to {host}:{port}). "
"CLICKHOUSE_SECURE enables HTTPS for the ClickHouse database connection "
"only — it is not MCP or ingress TLS. Use true for HTTPS database "
"endpoints (ClickHouse Cloud / port 8443) and false only for plain HTTP "
"(typical local Docker on 8123)."
)

# General connectivity and scheme/port failures can surface as opaque HTTP errors.
connection_failure_tokens = (
"http status",
"bad status line",
"connection refused",
"connection reset",
"remote end closed connection",
)
if any(token in err for token in connection_failure_tokens) and not hints:
hints.append(
f"Connection to {host}:{port} failed. Verify ClickHouse is running and reachable "
"at this address and that network or proxy routing permits access. Then confirm "
f"CLICKHOUSE_SECURE={str(secure).lower()} matches whether ClickHouse expects HTTPS, "
"and that CLICKHOUSE_PORT is an HTTP interface port (8123/8443), not a native TCP "
"port (9000/9440). These settings configure the database client, not the MCP "
"server transport."
)

return hints


def _format_connection_failure(error: Exception, client_config: dict) -> str:
"""Build a connection failure message with optional configuration hints."""
message = f"Failed to connect to ClickHouse: {error}"
hints = _connection_error_hints(error, client_config)
if hints:
message += "\n" + "\n".join(f"Hint: {hint}" for hint in hints)
return message


def create_clickhouse_client():
client_config = get_config().get_client_config()

Expand All @@ -562,6 +655,14 @@ def create_clickhouse_client():
# If we're outside a request context, just proceed with the default config
pass

port = client_config.get("port")
if port in _NATIVE_PROTOCOL_PORTS:
logger.warning(
"CLICKHOUSE_PORT=%s is a native TCP protocol port (clickhouse-client). "
"mcp-clickhouse uses the HTTP interface; prefer 8123 (HTTP) or 8443 (HTTPS).",
port,
)

config_fields = [
f"secure={client_config['secure']}",
f"verify={client_config['verify']}",
Expand All @@ -584,7 +685,8 @@ def create_clickhouse_client():
logger.info(f"Successfully connected to ClickHouse server version {version}")
return client
except Exception as e:
logger.error(f"Failed to connect to ClickHouse: {str(e)}")
message = _format_connection_failure(e, client_config)
logger.error(message)
raise
Comment thread
joe-clickhouse marked this conversation as resolved.


Expand Down
175 changes: 175 additions & 0 deletions tests/test_connection_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Tests for ClickHouse connection failure hints."""

from unittest.mock import MagicMock, patch

import pytest
from clickhouse_connect.driver.exceptions import OperationalError

from mcp_clickhouse.mcp_server import (
_NATIVE_PROTOCOL_PORTS,
_connection_error_hints,
_format_connection_failure,
create_clickhouse_client,
)


def test_native_protocol_ports_constant():
assert 9000 in _NATIVE_PROTOCOL_PORTS
assert 9440 in _NATIVE_PROTOCOL_PORTS
assert 8123 not in _NATIVE_PROTOCOL_PORTS
assert 8443 not in _NATIVE_PROTOCOL_PORTS


def test_hint_for_native_port_in_config():
error = Exception("Connection refused")
config = {"host": "localhost", "port": 9000, "secure": False}

hints = _connection_error_hints(error, config)

assert len(hints) == 1
assert "native TCP" in hints[0]
assert "8123" in hints[0]
assert "8443" in hints[0]


def test_hint_for_native_port_server_message():
error = Exception(
"HTTP driver received HTTP status 400, server response: "
"Port 9000 is for clickhouse-client program"
)
# User may have mapped something oddly; message still triggers the hint.
config = {"host": "xxx.us-east-1.aws.clickhouse.cloud", "port": 8443, "secure": True}

hints = _connection_error_hints(error, config)

assert len(hints) == 1
assert "reached native TCP port 9000" in hints[0]
assert "configured for xxx.us-east-1.aws.clickhouse.cloud:8443" in hints[0]
assert "Check DNS, service, proxy, load-balancer, and port mappings" in hints[0]
assert "CLICKHOUSE_PORT=8443 looks like" not in hints[0]


def test_hint_for_tls_mismatch():
error = Exception("ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number")
config = {"host": "db.example.com", "port": 8443, "secure": False}

hints = _connection_error_hints(error, config)

assert len(hints) == 1
assert "CLICKHOUSE_SECURE=false" in hints[0]
assert "database connection" in hints[0]
assert "MCP or ingress" in hints[0]


def test_hint_for_http_status_without_other_signals():
error = Exception("HTTP driver received HTTP status 400")
config = {"host": "localhost", "port": 8443, "secure": False}

hints = _connection_error_hints(error, config)

assert len(hints) == 1
assert "CLICKHOUSE_SECURE=false" in hints[0]
assert "HTTP interface" in hints[0]


def test_connection_refused_hint_starts_with_reachability():
error = Exception("Connection refused")
config = {"host": "localhost", "port": 8123, "secure": False}

hints = _connection_error_hints(error, config)

assert len(hints) == 1
assert "running and reachable" in hints[0]
assert "network or proxy routing" in hints[0]
assert "CLICKHOUSE_SECURE=false" in hints[0]


def test_no_hint_for_unrelated_errors():
error = Exception("Authentication failed: password is incorrect")
config = {"host": "localhost", "port": 8123, "secure": False}

hints = _connection_error_hints(error, config)

assert hints == []


def test_format_connection_failure_appends_hints():
error = Exception("Port 9000 is for clickhouse-client program")
config = {"host": "localhost", "port": 9000, "secure": False}

message = _format_connection_failure(error, config)

assert message.startswith("Failed to connect to ClickHouse:")
assert "Hint:" in message
assert "HTTP interface" in message


def test_format_connection_failure_without_hints():
error = Exception("Authentication failed")
config = {"host": "localhost", "port": 8123, "secure": False}

message = _format_connection_failure(error, config)

assert message == "Failed to connect to ClickHouse: Authentication failed"
assert "Hint:" not in message


@patch("mcp_clickhouse.mcp_server.clickhouse_connect")
def test_create_client_preserves_exception_and_logs_hint(mock_cc, monkeypatch, caplog):
import logging

monkeypatch.setenv("CLICKHOUSE_HOST", "localhost")
monkeypatch.setenv("CLICKHOUSE_USER", "default")
monkeypatch.setenv("CLICKHOUSE_PASSWORD", "secret")
monkeypatch.setenv("CLICKHOUSE_PORT", "8123")
monkeypatch.setenv("CLICKHOUSE_SECURE", "false")

# Reset config singleton so env changes are picked up
import mcp_clickhouse.mcp_env as mcp_env

mcp_env._CONFIG_INSTANCE = None

original_error = OperationalError(
"HTTP driver received HTTP status 400, server response: "
"Port 9000 is for clickhouse-client program"
)
mock_cc.get_client.side_effect = original_error

with caplog.at_level(logging.ERROR, logger="mcp-clickhouse"):
with pytest.raises(OperationalError) as exc_info:
create_clickhouse_client()

assert exc_info.value is original_error
log_message = "\n".join(record.message for record in caplog.records)
assert "Failed to connect to ClickHouse" in log_message
assert "Hint:" in log_message
assert "reached native TCP port 9000" in log_message

# Clean up singleton for other tests
mcp_env._CONFIG_INSTANCE = None


@patch("mcp_clickhouse.mcp_server.clickhouse_connect")
def test_create_client_warns_on_native_port(mock_cc, monkeypatch, caplog):
import logging

monkeypatch.setenv("CLICKHOUSE_HOST", "localhost")
monkeypatch.setenv("CLICKHOUSE_USER", "default")
monkeypatch.setenv("CLICKHOUSE_PASSWORD", "secret")
monkeypatch.setenv("CLICKHOUSE_PORT", "9000")
monkeypatch.setenv("CLICKHOUSE_SECURE", "false")

# Reset config singleton so env changes are picked up
import mcp_clickhouse.mcp_env as mcp_env

mcp_env._CONFIG_INSTANCE = None

mock_cc.get_client.return_value = MagicMock(server_version="24.1")

with caplog.at_level(logging.WARNING, logger="mcp-clickhouse"):
create_clickhouse_client()

assert any("native TCP protocol port" in record.message for record in caplog.records)

# Clean up singleton for other tests
mcp_env._CONFIG_INSTANCE = None
Loading