Skip to content

Commit 2aba66a

Browse files
Joe/171 oauth seems to have been broken in recent updates (#173)
* fix broken oauth * simplify tests * address PR comments
1 parent d44835d commit 2aba66a

6 files changed

Lines changed: 189 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
## Unreleased
66

7+
### Added
8+
- Support for FastMCP OAuth/OIDC auth providers on HTTP/SSE transports via the `FASTMCP_SERVER_AUTH` environment variable (e.g. Azure Entra, Google, GitHub, WorkOS). Static token, FastMCP OAuth, and disabled mode are now mutually exclusive; configure exactly one. ([#171](https://github.com/ClickHouse/mcp-clickhouse/issues/171))
9+
10+
### Changed
11+
- `/health` endpoint is now unauthenticated across all auth modes (previously gated only under static-token mode, which was asymmetric and incompatible with redirect-based OAuth providers). Response bodies trimmed to `OK` / generic error strings to avoid leaking ClickHouse version information or connection exception details; underlying errors are logged server-side.
12+
713
## 0.3.0 - 2026-04-14
814

915
### Added

README.md

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ An MCP server for ClickHouse.
4242
### Health Check Endpoint
4343

4444
When running with HTTP or SSE transport, a health check endpoint is available at `/health`. This endpoint:
45-
- Returns `200 OK` with the ClickHouse version if the server is healthy and can connect to ClickHouse
46-
- Returns `503 Service Unavailable` if the server cannot connect to ClickHouse
45+
- Returns `200 OK` (body: `OK`) if the server is healthy and can connect to ClickHouse
46+
- Returns `503 Service Unavailable` with a generic error message if the server cannot connect to ClickHouse
47+
48+
The endpoint is intentionally unauthenticated so orchestrator probes (e.g. Kubernetes liveness/readiness, load balancers) can reach it without credentials. The response body is deliberately minimal to avoid leaking backend version strings or error details; debug failures via the server logs.
4749

4850
Example:
4951
```bash
5052
curl http://localhost:8000/health
51-
# Response: OK - Connected to ClickHouse 24.3.1
53+
# Response: OK
5254
```
5355

5456
## Security
@@ -57,6 +59,16 @@ curl http://localhost:8000/health
5759

5860
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.
5961

62+
Three authentication modes are supported. Pick one:
63+
64+
| Mode | When to use | Env var |
65+
|----------------------------|-------------------------------------------|------------------------------------------------------------------------------------------------|
66+
| Static bearer token | Simple deployments, internal services | `CLICKHOUSE_MCP_AUTH_TOKEN` |
67+
| OAuth / OIDC (via FastMCP) | Azure Entra, Google, GitHub, WorkOS, etc. | `FASTMCP_SERVER_AUTH=<provider-class-path>` (+ provider-specific `FASTMCP_SERVER_AUTH_*` vars) |
68+
| Disabled | Local development only | `CLICKHOUSE_MCP_AUTH_DISABLED=true` |
69+
70+
Startup fails if none of these are configured for HTTP/SSE transports.
71+
6072
#### Setting Up Authentication
6173

6274
1. Generate a secure token (can be any random string):
@@ -89,10 +101,22 @@ When using HTTP or SSE transport, authentication is **required by default**. The
89101
}
90102
```
91103

92-
For command-line tools:
93-
```bash
94-
curl -H "Authorization: Bearer your-generated-token" http://localhost:8000/health
95-
```
104+
Note: the `/health` endpoint is intentionally unauthenticated (see [Health Check Endpoint](#health-check-endpoint) above). To verify that bearer-token auth is actually rejecting unauthenticated requests, hit the MCP endpoint itself e.g. with the MCP Inspector, or by POSTing a JSON-RPC request to `/mcp` with and without the `Authorization` header and confirming the unauthenticated call returns `401`.
105+
106+
#### OAuth / OIDC via FastMCP
107+
108+
For production deployments with identity providers (Azure Entra, Google, GitHub, WorkOS, etc.), delegate authentication to [FastMCP's built-in auth providers](https://gofastmcp.com/servers/auth) instead of using a static token. Set `FASTMCP_SERVER_AUTH` to the **full class path** of a FastMCP auth provider, along with the provider-specific `FASTMCP_SERVER_AUTH_*` variables, and leave `CLICKHOUSE_MCP_AUTH_TOKEN` unset.
109+
110+
Example (Azure Entra):
111+
112+
```bash
113+
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureProvider
114+
export FASTMCP_SERVER_AUTH_AZURE_TENANT_ID="<tenant-id>"
115+
export FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID="<client-id>"
116+
export FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET="<client-secret>"
117+
```
118+
119+
See the [FastMCP docs](https://gofastmcp.com/servers/auth) for the full list of providers and their required environment variables.
96120

97121
#### Development Mode (Disabling Authentication)
98122

@@ -453,11 +477,7 @@ CLICKHOUSE_PASSWORD=clickhouse
453477
CLICKHOUSE_MCP_SERVER_TRANSPORT=http CLICKHOUSE_MCP_AUTH_TOKEN="your-token" python -m mcp_clickhouse.main
454478

455479
# Then in another terminal:
456-
# Without auth (if disabled):
457480
curl http://localhost:8000/health
458-
459-
# With auth:
460-
curl -H "Authorization: Bearer your-token" http://localhost:8000/health
461481
```
462482

463483
### Environment Variables
@@ -515,11 +535,15 @@ The following environment variables are used to configure the ClickHouse and chD
515535
* `CLICKHOUSE_MCP_QUERY_TIMEOUT`: Timeout in seconds for SELECT tools
516536
* Default: `"30"`
517537
* Increase this if you see `Query timed out after ...` errors for heavy queries
518-
* `CLICKHOUSE_MCP_AUTH_TOKEN`: Authentication token for HTTP/SSE transports
538+
* `CLICKHOUSE_MCP_AUTH_TOKEN`: Static bearer token for HTTP/SSE transports
519539
* Default: None
520-
* **Required** when using HTTP or SSE transport (unless `CLICKHOUSE_MCP_AUTH_DISABLED=true`)
540+
* One of `CLICKHOUSE_MCP_AUTH_TOKEN`, `FASTMCP_SERVER_AUTH`, or `CLICKHOUSE_MCP_AUTH_DISABLED=true` is **required** for HTTP/SSE transports
521541
* Generate using `uuidgen` or `openssl rand -hex 32`
522542
* Clients must send this token in the `Authorization: Bearer <token>` header
543+
* `FASTMCP_SERVER_AUTH`: Delegate authentication to a [FastMCP auth provider](https://gofastmcp.com/servers/auth)
544+
* Default: None
545+
* Value is the **full class path** of an AuthProvider subclass, e.g. `fastmcp.server.auth.providers.azure.AzureProvider` or `fastmcp.server.auth.providers.google.GoogleProvider`
546+
* When set, FastMCP auto-loads the provider from its own `FASTMCP_SERVER_AUTH_*` environment variables; leave `CLICKHOUSE_MCP_AUTH_TOKEN` unset in this mode
523547
* `CLICKHOUSE_MCP_AUTH_DISABLED`: Disable authentication for HTTP/SSE transports
524548
* Default: `"false"` (authentication is enabled)
525549
* Set to `"true"` to disable authentication for local development/testing only
@@ -620,7 +644,7 @@ CLICKHOUSE_PASSWORD=clickhouse
620644
CLICKHOUSE_MCP_SERVER_TRANSPORT=http
621645
CLICKHOUSE_MCP_BIND_HOST=0.0.0.0 # Bind to all interfaces
622646
CLICKHOUSE_MCP_BIND_PORT=4200 # Custom port (default: 8000)
623-
CLICKHOUSE_MCP_AUTH_TOKEN=your-generated-token # Required for HTTP/SSE
647+
CLICKHOUSE_MCP_AUTH_TOKEN=your-generated-token # One auth mode required for HTTP/SSE (or FASTMCP_SERVER_AUTH, or CLICKHOUSE_MCP_AUTH_DISABLED=true)
624648
```
625649

626650
For local development with HTTP transport (authentication disabled):

mcp_clickhouse/mcp_env.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,12 @@ class MCPServerConfig:
300300
CLICKHOUSE_MCP_BIND_HOST: Bind host for HTTP/SSE (default: 127.0.0.1)
301301
CLICKHOUSE_MCP_BIND_PORT: Bind port for HTTP/SSE (default: 8000)
302302
CLICKHOUSE_MCP_QUERY_TIMEOUT: SELECT tool timeout in seconds (default: 30)
303-
CLICKHOUSE_MCP_AUTH_TOKEN: Authentication token for HTTP/SSE transports (required
304-
unless CLICKHOUSE_MCP_AUTH_DISABLED=true)
305-
CLICKHOUSE_MCP_AUTH_DISABLED: Disable authentication (default: false, use
306-
only for development)
303+
CLICKHOUSE_MCP_AUTH_TOKEN: Static bearer token for HTTP/SSE transports.
304+
One authentication mode must be configured for HTTP/SSE; the other two
305+
options are FASTMCP_SERVER_AUTH (FastMCP OAuth/OIDC providers) and
306+
CLICKHOUSE_MCP_AUTH_DISABLED=true.
307+
CLICKHOUSE_MCP_AUTH_DISABLED: Disable authentication entirely (default: false,
308+
development only)
307309
"""
308310

309311
@property

mcp_clickhouse/mcp_server.py

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -71,51 +71,73 @@ class Table:
7171

7272
load_dotenv()
7373

74-
# Configure authentication for HTTP/SSE transports
75-
auth_provider = None
76-
mcp_config = get_mcp_config()
77-
http_transports = [TransportType.HTTP.value, TransportType.SSE.value]
74+
_HTTP_TRANSPORTS = (TransportType.HTTP.value, TransportType.SSE.value)
75+
76+
77+
def _resolve_auth(mcp_config) -> Dict[str, Any]:
78+
"""Resolve FastMCP auth kwargs for the current transport.
79+
80+
An empty return dict omits the `auth` kwarg so FastMCP auto-detects its
81+
provider from FASTMCP_SERVER_AUTH / FASTMCP_SERVER_AUTH_* env vars.
82+
Returning {"auth": None} instead explicitly disables auth.
83+
"""
84+
if mcp_config.server_transport not in _HTTP_TRANSPORTS:
85+
return {}
86+
87+
configured = {
88+
"CLICKHOUSE_MCP_AUTH_DISABLED": mcp_config.auth_disabled,
89+
"CLICKHOUSE_MCP_AUTH_TOKEN": bool(mcp_config.auth_token),
90+
"FASTMCP_SERVER_AUTH": bool(os.getenv("FASTMCP_SERVER_AUTH")),
91+
}
92+
active = [name for name, is_set in configured.items() if is_set]
93+
94+
if len(active) > 1:
95+
raise ValueError(
96+
"Multiple authentication modes configured for HTTP/SSE transport: "
97+
f"{', '.join(active)}. These are mutually exclusive; unset all but one."
98+
)
99+
100+
if not active:
101+
raise ValueError(
102+
"Authentication is required for HTTP/SSE transports. Configure exactly one of:\n"
103+
" - CLICKHOUSE_MCP_AUTH_TOKEN=<token> (static bearer token)\n"
104+
" - FASTMCP_SERVER_AUTH=<class-path> (FastMCP auth provider, full class path;\n"
105+
" e.g. fastmcp.server.auth.providers.azure.AzureProvider)\n"
106+
" - CLICKHOUSE_MCP_AUTH_DISABLED=true (disables auth; development only)"
107+
)
78108

79-
if mcp_config.server_transport in http_transports:
80109
if mcp_config.auth_disabled:
81110
logger.warning("WARNING: MCP SERVER AUTHENTICATION IS DISABLED")
82111
logger.warning("Only use this for local development/testing.")
83112
logger.warning("DO NOT expose to networks.")
84-
elif mcp_config.auth_token:
85-
auth_provider = StaticTokenVerifier(
113+
return {"auth": None}
114+
115+
if mcp_config.auth_token:
116+
verifier = StaticTokenVerifier(
86117
tokens={mcp_config.auth_token: {"client_id": "mcp-client", "scopes": []}},
87118
required_scopes=[],
88119
)
89-
logger.info("Authentication enabled for HTTP/SSE transport")
90-
else:
91-
# No token configured and auth not disabled
92-
raise ValueError(
93-
"Authentication token required for HTTP/SSE transports. "
94-
"Set CLICKHOUSE_MCP_AUTH_TOKEN environment variable or set "
95-
"CLICKHOUSE_MCP_AUTH_DISABLED=true (for development only)."
96-
)
120+
logger.info("Authentication enabled for HTTP/SSE transport (static bearer token)")
121+
return {"auth": verifier}
122+
123+
logger.info(
124+
"Authentication delegated to FastMCP provider: %s", os.getenv("FASTMCP_SERVER_AUTH")
125+
)
126+
# Return empty kwargs so FastMCP auto-loads from FASTMCP_SERVER_AUTH_* env vars.
127+
return {}
128+
97129

98-
mcp = FastMCP(name=MCP_SERVER_NAME, auth=auth_provider)
130+
mcp = FastMCP(name=MCP_SERVER_NAME, **_resolve_auth(get_mcp_config()))
99131
_chdb_client = None
100132
_chdb_error_message: Optional[str] = None
101133

102134

103135
@mcp.custom_route("/health", methods=["GET"])
104136
async def health_check(request: Request) -> PlainTextResponse:
105-
"""Health check endpoint for monitoring server status.
137+
"""Liveness probe. Intentionally unauthenticated and minimal.
106138
107-
Returns OK if the server is running and can connect to ClickHouse.
139+
Debug via server logs.
108140
"""
109-
if auth_provider is not None:
110-
auth_header = request.headers.get("Authorization")
111-
if not auth_header or not auth_header.startswith("Bearer "):
112-
return PlainTextResponse("Unauthorized", status_code=401)
113-
114-
token = auth_header[7:]
115-
access_token = await auth_provider.verify_token(token)
116-
if access_token is None:
117-
return PlainTextResponse("Unauthorized", status_code=401)
118-
119141
try:
120142
# Check if ClickHouse is enabled by trying to create config
121143
# If ClickHouse is disabled, this will succeed but connection will fail
@@ -125,26 +147,31 @@ async def health_check(request: Request) -> PlainTextResponse:
125147
# If ClickHouse is disabled, check chDB status
126148
chdb_config = get_chdb_config()
127149
if chdb_config.enabled and _chdb_client is not None:
128-
return PlainTextResponse("OK - MCP server running with chDB enabled")
150+
return PlainTextResponse("OK")
129151
elif chdb_config.enabled and _chdb_error_message:
130152
return PlainTextResponse(
131153
"ERROR. chDB initialization failed. Check server logs for details.",
132154
status_code=503,
133155
)
134156
else:
135-
# Both ClickHouse and chDB are disabled - this is an error
157+
logger.error(
158+
"Health check failed: both CLICKHOUSE_ENABLED=false and CHDB_ENABLED=false"
159+
)
136160
return PlainTextResponse(
137-
"ERROR - Both ClickHouse and chDB are disabled. At least one must be enabled.",
161+
"ERROR. Server misconfigured. Check server logs for details.",
138162
status_code=503,
139163
)
140164

141165
# Try to create a client connection to verify ClickHouse connectivity
142-
client = create_clickhouse_client()
143-
version = client.server_version
144-
return PlainTextResponse(f"OK - Connected to ClickHouse {version}")
145-
except Exception as e:
146-
# Return 503 Service Unavailable if we can't connect to ClickHouse
147-
return PlainTextResponse(f"ERROR - Cannot connect to ClickHouse: {str(e)}", status_code=503)
166+
create_clickhouse_client()
167+
return PlainTextResponse("OK")
168+
except Exception:
169+
# Log the underlying error server-side, but don't leak details over the wire.
170+
logger.exception("Health check failed: ClickHouse connection error")
171+
return PlainTextResponse(
172+
"ERROR. ClickHouse connection failed. Check server logs for details.",
173+
status_code=503,
174+
)
148175

149176

150177
def result_to_table(query_columns, result) -> List[Table]:

tests/test_auth_config.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from mcp_clickhouse.mcp_env import MCPServerConfig
4+
from mcp_clickhouse.mcp_server import _resolve_auth
45

56

67
def test_auth_token_configuration(monkeypatch: pytest.MonkeyPatch):
@@ -68,3 +69,50 @@ def test_auth_token_with_sse_transport(monkeypatch: pytest.MonkeyPatch):
6869
assert config.server_transport == "sse"
6970
assert config.auth_token == "sse-auth-token"
7071
assert config.auth_disabled is False
72+
73+
74+
def _clear_auth_env(monkeypatch: pytest.MonkeyPatch) -> None:
75+
for var in (
76+
"CLICKHOUSE_MCP_AUTH_TOKEN",
77+
"CLICKHOUSE_MCP_AUTH_DISABLED",
78+
"FASTMCP_SERVER_AUTH",
79+
):
80+
monkeypatch.delenv(var, raising=False)
81+
82+
83+
def test_resolve_auth_oauth_omits_auth_kwarg(monkeypatch: pytest.MonkeyPatch):
84+
"""FASTMCP_SERVER_AUTH alone returns empty kwargs (no `auth` key)."""
85+
_clear_auth_env(monkeypatch)
86+
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "http")
87+
monkeypatch.setenv("FASTMCP_SERVER_AUTH", "fastmcp.server.auth.providers.jwt.JWTVerifier")
88+
89+
assert _resolve_auth(MCPServerConfig()) == {}
90+
91+
92+
def test_resolve_auth_disabled_passes_explicit_none(monkeypatch: pytest.MonkeyPatch):
93+
"""CLICKHOUSE_MCP_AUTH_DISABLED=true returns {"auth": None}, not {}."""
94+
_clear_auth_env(monkeypatch)
95+
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "http")
96+
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_DISABLED", "true")
97+
98+
assert _resolve_auth(MCPServerConfig()) == {"auth": None}
99+
100+
101+
def test_resolve_auth_rejects_multiple_modes(monkeypatch: pytest.MonkeyPatch):
102+
"""Configuring more than one auth mode raises ValueError."""
103+
_clear_auth_env(monkeypatch)
104+
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "http")
105+
monkeypatch.setenv("CLICKHOUSE_MCP_AUTH_TOKEN", "secret")
106+
monkeypatch.setenv("FASTMCP_SERVER_AUTH", "fastmcp.server.auth.providers.jwt.JWTVerifier")
107+
108+
with pytest.raises(ValueError, match="mutually exclusive"):
109+
_resolve_auth(MCPServerConfig())
110+
111+
112+
def test_resolve_auth_http_without_any_mode_raises(monkeypatch: pytest.MonkeyPatch):
113+
"""HTTP transport with no auth configured raises ValueError."""
114+
_clear_auth_env(monkeypatch)
115+
monkeypatch.setenv("CLICKHOUSE_MCP_SERVER_TRANSPORT", "http")
116+
117+
with pytest.raises(ValueError):
118+
_resolve_auth(MCPServerConfig())

tests/test_optional_chdb.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,30 @@ async def test_health_check_hides_internal_chdb_init_error_details():
114114
assert b"initialization failed" in body
115115
assert b"check server logs for details" in body
116116
assert b"/tmp/private.db" not in body
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_health_check_hides_clickhouse_connection_error_details():
121+
"""A 503 response from a connection failure does not include the exception's hostname or credentials."""
122+
request = Request({"type": "http", "method": "GET", "headers": []})
123+
124+
def raise_with_secrets():
125+
raise ConnectionError(
126+
"HTTPConnectionPool(host='internal-ch.prod.mycorp.local', port=8443): "
127+
"password=hunter2 failed"
128+
)
129+
130+
with (
131+
patch.dict("os.environ", {"CLICKHOUSE_ENABLED": "true"}, clear=False),
132+
patch.object(
133+
mcp_server, "create_clickhouse_client", side_effect=raise_with_secrets
134+
),
135+
):
136+
response = await mcp_server.health_check(request)
137+
138+
assert response.status_code == 503
139+
body = response.body.lower()
140+
assert b"clickhouse connection failed" in body
141+
assert b"check server logs for details" in body
142+
assert b"internal-ch.prod.mycorp.local" not in body
143+
assert b"hunter2" not in body

0 commit comments

Comments
 (0)