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
86 changes: 81 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,58 @@ curl http://localhost:8000/health
# Response: OK - Connected to ClickHouse 24.3.1
```

## Security

### Authentication for HTTP/SSE Transports

When using HTTP or SSE transport, authentication is **required by default**. The `stdio` transport (default) does not require authentication as it only communicates via standard input/output.

#### Setting Up Authentication

1. Generate a secure token (can be any random string):
```bash
# Using uuidgen (macOS/Linux)
uuidgen

# Using openssl
openssl rand -hex 32
```

2. Configure the server with the token:
```bash
export CLICKHOUSE_MCP_AUTH_TOKEN="your-generated-token"
```

3. Configure your MCP client to include the token in requests:

For Claude Desktop with HTTP/SSE transport:
```json
{
"mcpServers": {
"mcp-clickhouse": {
"url": "http://127.0.0.1:8000",
"headers": {
"Authorization": "Bearer your-generated-token"
}
}
}
}
```

For command-line tools:
```bash
curl -H "Authorization: Bearer your-generated-token" http://localhost:8000/health
```

#### Development Mode (Disabling Authentication)

For local development and testing only, you can disable authentication by setting:
```bash
export CLICKHOUSE_MCP_AUTH_DISABLED=true
```

**WARNING:** Only use this for local development. Do not disable authentication when the server is exposed to any network.

## Configuration

This MCP server supports both ClickHouse and chDB. You can enable either or both depending on your needs.
Expand Down Expand Up @@ -269,14 +321,18 @@ CLICKHOUSE_PASSWORD=clickhouse

5. To test with HTTP transport and the health check endpoint:
```bash
# Using default port 8000
CLICKHOUSE_MCP_SERVER_TRANSPORT=http python -m mcp_clickhouse.main
# For development, disable authentication
CLICKHOUSE_MCP_SERVER_TRANSPORT=http CLICKHOUSE_MCP_AUTH_DISABLED=true python -m mcp_clickhouse.main

# Or with a custom port
CLICKHOUSE_MCP_SERVER_TRANSPORT=http CLICKHOUSE_MCP_BIND_PORT=4200 python -m mcp_clickhouse.main
# Or with authentication (generate a token first)
CLICKHOUSE_MCP_SERVER_TRANSPORT=http CLICKHOUSE_MCP_AUTH_TOKEN="your-token" python -m mcp_clickhouse.main

# Then in another terminal:
curl http://localhost:8000/health # or http://localhost:4200/health for custom port
# Without auth (if disabled):
curl http://localhost:8000/health

# With auth:
curl -H "Authorization: Bearer your-token" http://localhost:8000/health
```

### Environment Variables
Expand Down Expand Up @@ -331,6 +387,15 @@ The following environment variables are used to configure the ClickHouse and chD
* `CLICKHOUSE_MCP_QUERY_TIMEOUT`: Timeout in seconds for SELECT tools
* Default: `"30"`
* Increase this if you see `Query timed out after ...` errors for heavy queries
* `CLICKHOUSE_MCP_AUTH_TOKEN`: Authentication token for HTTP/SSE transports
* Default: None
* **Required** when using HTTP or SSE transport (unless `CLICKHOUSE_MCP_AUTH_DISABLED=true`)
* Generate using `uuidgen` or `openssl rand -hex 32`
* Clients must send this token in the `Authorization: Bearer <token>` header
* `CLICKHOUSE_MCP_AUTH_DISABLED`: Disable authentication for HTTP/SSE transports
* Default: `"false"` (authentication is enabled)
* Set to `"true"` to disable authentication for local development/testing only
* **WARNING:** Only use for local development. Do not disable when exposed to networks
* `CLICKHOUSE_ENABLED`: Enable/disable ClickHouse functionality
* Default: `"true"`
* Set to `"false"` to disable ClickHouse tools when using chDB only
Expand Down Expand Up @@ -409,6 +474,17 @@ CLICKHOUSE_PASSWORD=clickhouse
CLICKHOUSE_MCP_SERVER_TRANSPORT=http
CLICKHOUSE_MCP_BIND_HOST=0.0.0.0 # Bind to all interfaces
CLICKHOUSE_MCP_BIND_PORT=4200 # Custom port (default: 8000)
CLICKHOUSE_MCP_AUTH_TOKEN=your-generated-token # Required for HTTP/SSE
```

For local development with HTTP transport (authentication disabled):

```env
CLICKHOUSE_HOST=localhost
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=clickhouse
CLICKHOUSE_MCP_SERVER_TRANSPORT=http
CLICKHOUSE_MCP_AUTH_DISABLED=true # Only for local development!
```

When using HTTP transport, the server will run on the configured port (default 8000). For example, with the above configuration:
Expand Down
14 changes: 14 additions & 0 deletions mcp_clickhouse/mcp_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ class MCPServerConfig:
CLICKHOUSE_MCP_BIND_HOST: Bind host for HTTP/SSE (default: 127.0.0.1)
CLICKHOUSE_MCP_BIND_PORT: Bind port for HTTP/SSE (default: 8000)
CLICKHOUSE_MCP_QUERY_TIMEOUT: SELECT tool timeout in seconds (default: 30)
CLICKHOUSE_MCP_AUTH_TOKEN: Authentication token for HTTP/SSE transports (required
unless CLICKHOUSE_MCP_AUTH_DISABLED=true)
CLICKHOUSE_MCP_AUTH_DISABLED: Disable authentication (default: false, use
only for development)
"""

@property
Expand All @@ -292,6 +296,16 @@ def bind_port(self) -> int:
def query_timeout(self) -> int:
return int(os.getenv("CLICKHOUSE_MCP_QUERY_TIMEOUT", "30"))

@property
def auth_token(self) -> Optional[str]:
"""Get the authentication token for HTTP/SSE transports."""
return os.getenv("CLICKHOUSE_MCP_AUTH_TOKEN", None)

@property
def auth_disabled(self) -> bool:
"""Get whether authentication is disabled."""
return os.getenv("CLICKHOUSE_MCP_AUTH_DISABLED", "false").lower() == "true"


_MCP_CONFIG_INSTANCE = None

Expand Down
39 changes: 37 additions & 2 deletions mcp_clickhouse/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
from starlette.requests import Request
from starlette.responses import PlainTextResponse

from mcp_clickhouse.mcp_env import get_config, get_chdb_config, get_mcp_config
from mcp_clickhouse.mcp_env import get_config, get_chdb_config, get_mcp_config, TransportType
from mcp_clickhouse.chdb_prompt import CHDB_PROMPT
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier


@dataclass
Expand Down Expand Up @@ -68,7 +69,31 @@ class Table:

load_dotenv()

mcp = FastMCP(name=MCP_SERVER_NAME)
# Configure authentication for HTTP/SSE transports
auth_provider = None
mcp_config = get_mcp_config()
http_transports = [TransportType.HTTP.value, TransportType.SSE.value]

if mcp_config.server_transport in http_transports:
if mcp_config.auth_disabled:
logger.warning("WARNING: MCP SERVER AUTHENTICATION IS DISABLED")
logger.warning("Only use this for local development/testing.")
logger.warning("DO NOT expose to networks.")
elif mcp_config.auth_token:
auth_provider = StaticTokenVerifier(
tokens={mcp_config.auth_token: {"client_id": "mcp-client", "scopes": []}},
required_scopes=[],
)
logger.info("Authentication enabled for HTTP/SSE transport")
else:
# No token configured and auth not disabled
raise ValueError(
"Authentication token required for HTTP/SSE transports. "
"Set CLICKHOUSE_MCP_AUTH_TOKEN environment variable or set "
"CLICKHOUSE_MCP_AUTH_DISABLED=true (for development only)."
)

mcp = FastMCP(name=MCP_SERVER_NAME, auth=auth_provider)


@mcp.custom_route("/health", methods=["GET"])
Expand All @@ -77,6 +102,16 @@ async def health_check(request: Request) -> PlainTextResponse:

Returns OK if the server is running and can connect to ClickHouse.
"""
if auth_provider is not None:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return PlainTextResponse("Unauthorized", status_code=401)

token = auth_header[7:]
access_token = await auth_provider.verify_token(token)
if access_token is None:
return PlainTextResponse("Unauthorized", status_code=401)

try:
# Check if ClickHouse is enabled by trying to create config
# If ClickHouse is disabled, this will succeed but connection will fail
Expand Down
70 changes: 70 additions & 0 deletions tests/test_auth_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import pytest

from mcp_clickhouse.mcp_env import MCPServerConfig


def test_auth_token_configuration(monkeypatch: pytest.MonkeyPatch):
"""Test that auth token is correctly configured when set."""
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_TOKEN", "test-secret-token")

config = MCPServerConfig()

assert config.auth_token == "test-secret-token"
assert config.auth_disabled is False


def test_auth_disabled_configuration(monkeypatch: pytest.MonkeyPatch):
"""Test that auth can be disabled when CLICKHOUSE_MCP_AUTH_DISABLED=true."""
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_DISABLED", "true")
monkeypatch.delenv("CLICKHOUSE_MCP_AUTH_TOKEN", raising=False)

config = MCPServerConfig()

assert config.auth_disabled is True
assert config.auth_token is None


def test_auth_enabled_by_default(monkeypatch: pytest.MonkeyPatch):
"""Test that auth is enabled by default (auth_disabled=False)."""
monkeypatch.delenv("CLICKHOUSE_MCP_AUTH_DISABLED", raising=False)
monkeypatch.delenv("CLICKHOUSE_MCP_AUTH_TOKEN", raising=False)

config = MCPServerConfig()

assert config.auth_disabled is False
assert config.auth_token is None


def test_auth_token_with_stdio_transport(monkeypatch: pytest.MonkeyPatch):
"""Test that auth token is available but not required for stdio transport."""
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "stdio")
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_TOKEN", "test-token")

config = MCPServerConfig()

assert config.server_transport == "stdio"
assert config.auth_token == "test-token"


def test_auth_token_with_http_transport(monkeypatch: pytest.MonkeyPatch):
"""Test that auth token is correctly configured for HTTP transport."""
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "http")
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_TOKEN", "http-auth-token")

config = MCPServerConfig()

assert config.server_transport == "http"
assert config.auth_token == "http-auth-token"
assert config.auth_disabled is False


def test_auth_token_with_sse_transport(monkeypatch: pytest.MonkeyPatch):
"""Test that auth token is correctly configured for SSE transport."""
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "sse")
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_TOKEN", "sse-auth-token")

config = MCPServerConfig()

assert config.server_transport == "sse"
assert config.auth_token == "sse-auth-token"
assert config.auth_disabled is False