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 diff --git a/mcp_clickhouse/mcp_server.py b/mcp_clickhouse/mcp_server.py index c8a12243..c116e0b4 100644 --- a/mcp_clickhouse/mcp_server.py +++ b/mcp_clickhouse/mcp_server.py @@ -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", "") + + 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() @@ -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']}", @@ -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 diff --git a/tests/test_connection_errors.py b/tests/test_connection_errors.py new file mode 100644 index 00000000..ded18a8f --- /dev/null +++ b/tests/test_connection_errors.py @@ -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