From cb9ccaf4ff09f4eb4142c4a57265746c4b90af9b Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 18:58:45 +0530 Subject: [PATCH 1/5] fix: add actionable hints on ClickHouse connection failures Detect common misconfigurations when the HTTP client fails to connect: native TCP ports (9000/9440), TLS/scheme mismatches involving CLICKHOUSE_SECURE, and opaque HTTP errors. Surface clear hints in logs and raised errors so users do not confuse database client settings with MCP transport or ingress TLS. Also warn proactively when CLICKHOUSE_PORT is a known native protocol port. Related to #102 and the setup confusion described in #172. --- mcp_clickhouse/mcp_server.py | 92 ++++++++++++++++++++- tests/test_connection_errors.py | 140 ++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tests/test_connection_errors.py diff --git a/mcp_clickhouse/mcp_server.py b/mcp_clickhouse/mcp_server.py index c8a12243..3e4f77ee 100644 --- a/mcp_clickhouse/mcp_server.py +++ b/mcp_clickhouse/mcp_server.py @@ -543,6 +543,85 @@ 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", "") + + native_port_message = ( + "port 9000 is for clickhouse-client" in err or "port 9440 is for clickhouse-client" in err + ) + if port in _NATIVE_PROTOCOL_PORTS or native_port_message: + 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." + ) + + 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)." + ) + + # Scheme/port mismatches often surface as opaque HTTP client errors. + http_mismatch_tokens = ( + "http status", + "bad status line", + "connection refused", + "connection reset", + "remote end closed connection", + ) + if any(token in err for token in http_mismatch_tokens) and not hints: + hints.append( + f"Connection to {host}:{port} failed with CLICKHOUSE_SECURE=" + f"{str(secure).lower()}. Confirm CLICKHOUSE_SECURE 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() @@ -562,6 +641,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']}", @@ -584,8 +671,9 @@ 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)}") - raise + message = _format_connection_failure(e, client_config) + logger.error(message) + raise RuntimeError(message) from e def build_query_settings(client) -> dict[str, str]: diff --git a/tests/test_connection_errors.py b/tests/test_connection_errors.py new file mode 100644 index 00000000..ac2f8ea8 --- /dev/null +++ b/tests/test_connection_errors.py @@ -0,0 +1,140 @@ +"""Tests for ClickHouse connection failure hints.""" + +from unittest.mock import MagicMock, patch + +import pytest + +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 any("HTTP interface" in hint for hint in hints) + assert any("9000/9440" in hint for hint in hints) + + +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_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_raises_runtime_error_with_hint(mock_cc): + mock_cc.get_client.side_effect = Exception( + "HTTP driver received HTTP status 400, server response: " + "Port 9000 is for clickhouse-client program" + ) + + with pytest.raises(RuntimeError) as exc_info: + create_clickhouse_client() + + message = str(exc_info.value) + assert "Failed to connect to ClickHouse" in message + assert "Hint:" in message + assert "HTTP interface" in message + + +@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 From caf8a6b624449eef85933370b19465d735f89ab3 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 19:18:54 +0530 Subject: [PATCH 2/5] chore: re-trigger CLA check after signing From 39a670e68ca76aa2f7ba4c1b1b097389bf2e6a1a Mon Sep 17 00:00:00 2001 From: Joe S Date: Wed, 22 Jul 2026 13:53:36 -0700 Subject: [PATCH 3/5] correct connection error diagnostics --- mcp_clickhouse/mcp_server.py | 38 ++++++++++++++++++++---------- tests/test_connection_errors.py | 41 +++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/mcp_clickhouse/mcp_server.py b/mcp_clickhouse/mcp_server.py index 3e4f77ee..c116e0b4 100644 --- a/mcp_clickhouse/mcp_server.py +++ b/mcp_clickhouse/mcp_server.py @@ -561,16 +561,29 @@ def _connection_error_hints(error: Exception, client_config: dict) -> List[str]: secure = bool(client_config.get("secure")) host = client_config.get("host", "") - native_port_message = ( - "port 9000 is for clickhouse-client" in err or "port 9440 is for clickhouse-client" in err + 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 or native_port_message: + 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", @@ -593,21 +606,22 @@ def _connection_error_hints(error: Exception, client_config: dict) -> List[str]: "(typical local Docker on 8123)." ) - # Scheme/port mismatches often surface as opaque HTTP client errors. - http_mismatch_tokens = ( + # 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 http_mismatch_tokens) and not hints: + if any(token in err for token in connection_failure_tokens) and not hints: hints.append( - f"Connection to {host}:{port} failed with CLICKHOUSE_SECURE=" - f"{str(secure).lower()}. Confirm CLICKHOUSE_SECURE 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." + 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 @@ -673,7 +687,7 @@ def create_clickhouse_client(): except Exception as e: message = _format_connection_failure(e, client_config) logger.error(message) - raise RuntimeError(message) from e + raise def build_query_settings(client) -> dict[str, str]: diff --git a/tests/test_connection_errors.py b/tests/test_connection_errors.py index ac2f8ea8..80bc0295 100644 --- a/tests/test_connection_errors.py +++ b/tests/test_connection_errors.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest +from clickhouse_connect.driver.exceptions import OperationalError from mcp_clickhouse.mcp_server import ( _NATIVE_PROTOCOL_PORTS, @@ -41,8 +42,11 @@ def test_hint_for_native_port_server_message(): hints = _connection_error_hints(error, config) - assert any("HTTP interface" in hint for hint in hints) - assert any("9000/9440" in hint for hint in hints) + 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(): @@ -68,6 +72,18 @@ def test_hint_for_http_status_without_other_signals(): 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} @@ -99,19 +115,24 @@ def test_format_connection_failure_without_hints(): @patch("mcp_clickhouse.mcp_server.clickhouse_connect") -def test_create_client_raises_runtime_error_with_hint(mock_cc): - mock_cc.get_client.side_effect = Exception( +def test_create_client_preserves_exception_and_logs_hint(mock_cc, caplog): + import logging + + 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 pytest.raises(RuntimeError) as exc_info: - create_clickhouse_client() + with caplog.at_level(logging.ERROR, logger="mcp-clickhouse"): + with pytest.raises(OperationalError) as exc_info: + create_clickhouse_client() - message = str(exc_info.value) - assert "Failed to connect to ClickHouse" in message - assert "Hint:" in message - assert "HTTP interface" in message + 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 @patch("mcp_clickhouse.mcp_server.clickhouse_connect") From 33a9a6d42317dd165c665061e20a84c1f1279bb3 Mon Sep 17 00:00:00 2001 From: Joe S Date: Wed, 22 Jul 2026 14:31:40 -0700 Subject: [PATCH 4/5] monkeypatch req'd env vars --- tests/test_connection_errors.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_connection_errors.py b/tests/test_connection_errors.py index 80bc0295..ded18a8f 100644 --- a/tests/test_connection_errors.py +++ b/tests/test_connection_errors.py @@ -115,9 +115,20 @@ def test_format_connection_failure_without_hints(): @patch("mcp_clickhouse.mcp_server.clickhouse_connect") -def test_create_client_preserves_exception_and_logs_hint(mock_cc, caplog): +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" @@ -134,6 +145,9 @@ def test_create_client_preserves_exception_and_logs_hint(mock_cc, caplog): 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): From 8c7d245c5b084e8771cefc6e2149809fd6b9bacd Mon Sep 17 00:00:00 2001 From: Joe S Date: Wed, 22 Jul 2026 14:55:07 -0700 Subject: [PATCH 5/5] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a195c5d4..2d875ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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