Skip to content

Commit 50ca5e1

Browse files
chDB-only mode: introspection tools + security baseline, on chdb.agents.ChDBTool
Adds a richer chDB-only tool surface (registered only when CLICKHOUSE_ENABLED=false and CHDB_ENABLED=true), alongside the existing run_chdb_select_query: - list_databases / list_tables / describe_table / get_sample_data / list_functions as bare, ClickHouse-server-parity tool names (collision-free in chDB-only mode). - Capability layer unified on chdb.agents.ChDBTool (chdb>=4.2.0): query execution, identifier quoting, parameter binding, result caps, typed errors, engine read-only, timeout, and the file-path allowlist all come from ChDBTool — mcp re-implements none of it. (database, table) maps to ChDBTool's database=. - Presentation aligned to the ClickHouse-server tools: success is bare JSON, and any failure is raised as ToolError (like run_query). max_result_bytes and allow_write are read from ChDBConfig (single source), and allow_write is passed to register so the shared session is not clobbered to readonly=2 in write mode. - CHDB_FILE_ALLOWLIST is a *path* allowlist (DuckDB allowed_directories style), enforced via chdb.agents.safety so raw SQL and ChDBTool agree: file-like paths under an allowed prefix are permitted, paths outside and DSN-based external sources are refused. run_chdb_select_query stays on its own executor (routing it through ChDBTool would flip 64-bit ints to quoted strings — a visible change). Security baseline retained on the session (SET readonly=2 unless writes allowed, max_result_bytes, timeout). Both-engine mode unchanged. Tests: 64 chDB tests pass against a clean install of chdb 4.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ccef141 commit 50ca5e1

11 files changed

Lines changed: 739 additions & 13 deletions

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ An MCP server for ClickHouse.
3939
* Query data directly from various sources (files, URLs, databases) without ETL processes.
4040
* Requires the optional `chdb` extra: `pip install 'mcp-clickhouse[chdb]'`
4141

42+
#### chDB-only introspection tools
43+
44+
When chDB is the only enabled engine (`CHDB_ENABLED=true` and `CLICKHOUSE_ENABLED=false`), the
45+
following read-only introspection tools are also registered, alongside `run_chdb_select_query`.
46+
They are not registered when the ClickHouse server is enabled (the ClickHouse tools above own those
47+
names in that mode). User-supplied identifiers are validated and quoted, and output is truncated to
48+
`CHDB_MAX_RESULT_BYTES`.
49+
50+
* `list_databases`: List databases in the chDB engine.
51+
* `list_tables`: List tables in a chDB database. Input: `database` (string).
52+
* `describe_table`: Return column names and types for a chDB table. Input: `database`, `table` (strings).
53+
* `get_sample_data`: Return the first rows of a chDB table. Input: `database`, `table` (strings), `limit` (int, default 10, clamped to 1–1000).
54+
* `list_functions`: List SQL functions available in the chDB engine. Input: `pattern` (optional string, case-insensitive name filter).
55+
56+
#### chDB-only security baseline
57+
58+
In chDB-only mode the chDB session also runs under a security baseline so the agent-facing
59+
query surface is bounded by default:
60+
61+
* **Read-only by default**: the session is put under `SET readonly=2`, which rejects
62+
INSERT/CREATE/DROP/ALTER on persistent tables while keeping `SET` and table functions usable.
63+
Set `CHDB_ALLOW_WRITE_ACCESS=true` to allow writes.
64+
* **Bounded output**: results are capped at `CHDB_MAX_RESULT_BYTES` both at the engine level
65+
(`max_result_bytes`) and as a final truncation of the serialized payload.
66+
* **Bounded runtime**: queries are aborted past the shared query timeout (`max_execution_time`).
67+
* **Optional source sandbox**: set `CHDB_FILE_ALLOWLIST` to refuse external/file table functions
68+
(`file`/`url`/`s3`/`remote`/`postgresql`/`executable`/`python`/…) in raw `run_chdb_select_query`
69+
SQL. The list of disallowed functions is taken from the live engine's `system.table_functions`.
70+
4271
### Health Check Endpoint
4372

4473
When running with HTTP or SSE transport, a health check endpoint is available at `/health`. This endpoint:
@@ -579,6 +608,15 @@ The following environment variables are used to configure the ClickHouse and chD
579608
* Default: `":memory:"` (in-memory database)
580609
* Use `:memory:` for in-memory database
581610
* Use a file path for persistent storage (e.g., `/path/to/chdb/data`)
611+
* `CHDB_MAX_RESULT_BYTES`: Maximum size (in bytes) of chDB output; capped at the engine level and truncated again on the serialized payload, with a notice
612+
* Default: `1048576` (1 MiB)
613+
* Applies in chDB-only mode (introspection tools and `run_chdb_select_query`)
614+
* `CHDB_ALLOW_WRITE_ACCESS`: Allow writes against the chDB session
615+
* Default: `"false"` (session runs under `SET readonly=2`)
616+
* Set to `"true"` to permit INSERT/CREATE/DROP/ALTER
617+
* `CHDB_FILE_ALLOWLIST`: Colon-separated path prefixes that sandbox the chDB engine
618+
* Default: unset (no gating — behavior unchanged)
619+
* When set, `run_chdb_select_query` refuses external/file table functions (`file`/`url`/`s3`/`remote`/…) in raw SQL
582620

583621
#### Example Configurations
584622

mcp_clickhouse/chdb_safety.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Self-contained output-bounding helper for the chDB query path.
2+
3+
A pure function with no third-party or project dependencies, so it stays
4+
importable when the optional ``chdb`` extra is not installed. SQL source
5+
scanning for the file allowlist (table-function detection, path checks) ships
6+
in ``chdb.agents.safety`` and is imported lazily on the chDB-only code paths.
7+
8+
Identifier/string quoting for the introspection tools is NOT here — those tools
9+
go through ``chdb.agents.ChDBTool``, which owns quoting and parameter binding.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
_TRUNCATION_NOTICE = (
15+
"\n\n[... output truncated at {limit} bytes; narrow the query or raise "
16+
"CHDB_MAX_RESULT_BYTES ...]"
17+
)
18+
19+
20+
def truncate_text(text: str, limit_bytes: int) -> str:
21+
"""Trim text to at most ``limit_bytes`` UTF-8 bytes, appending a notice if cut.
22+
23+
Trimming is done on the encoded bytes (not characters) so the byte budget is
24+
respected exactly; a partial trailing multi-byte character is dropped.
25+
"""
26+
encoded = text.encode("utf-8")
27+
if len(encoded) <= limit_bytes:
28+
return text
29+
trimmed = encoded[:limit_bytes].decode("utf-8", errors="ignore")
30+
return trimmed + _TRUNCATION_NOTICE.format(limit=limit_bytes)

mcp_clickhouse/chdb_tools.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""chDB-only introspection tools for mcp-clickhouse.
2+
3+
These tools are exposed ONLY in chDB-only mode (chDB enabled, ClickHouse server
4+
disabled). In that mode the ClickHouse-server tools are not registered, so the
5+
bare canonical names are free; these take them (``list_databases``,
6+
``list_tables``, ``describe_table``, ``get_sample_data``, ``list_functions``)
7+
and operate on the in-process chDB engine, alongside the existing
8+
``run_chdb_select_query`` tool.
9+
10+
Implementation: this module is a thin adapter over ``chdb.agents.ChDBTool`` (the
11+
canonical, cross-language chDB agent-tool contract). ChDBTool owns the shared
12+
behavior — read-only enforcement, parameter binding, identifier quoting, result
13+
caps, typed errors, and an optional query timeout — so mcp-clickhouse does not
14+
re-implement any of it. ChDBTool never mutates the externally owned session it
15+
is handed: at construction it verifies that the session's ``readonly`` setting
16+
matches the declared ``read_only`` flag and fails with a CONFIG_MISMATCH error
17+
otherwise (the server locks the session to ``readonly=2`` at init). The tool
18+
signatures here keep the ClickHouse-server-parity ``(database, table)`` shape
19+
and map onto ChDBTool's ``database=`` qualifier. See chdb.agents.CONTRACT.md.
20+
21+
Decoupling: self-contained; injected by the single caller
22+
(``register_chdb_only_tools``) with a chDB-client factory, the shared executor,
23+
and a query-timeout provider. The ClickHouse code path does not import this.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import asyncio
29+
import concurrent.futures
30+
import json
31+
import logging
32+
from typing import Callable, Optional
33+
34+
from fastmcp.exceptions import ToolError
35+
from fastmcp.tools import Tool
36+
37+
logger = logging.getLogger(__name__)
38+
39+
40+
def register_chdb_only_tools(
41+
mcp,
42+
*,
43+
max_result_bytes: int,
44+
create_client: Callable[[], object],
45+
query_executor: concurrent.futures.ThreadPoolExecutor,
46+
query_timeout: Callable[[], int],
47+
allow_write: bool = False,
48+
) -> None:
49+
"""Register the chDB-only introspection tools on the FastMCP instance.
50+
51+
Call exactly once, only in chDB-only mode. Arguments are injected to keep
52+
this module decoupled from the ClickHouse server code:
53+
54+
- ``max_result_bytes``: output byte cap, taken from ``ChDBConfig`` by the
55+
caller (not re-read from the environment here).
56+
- ``create_client``: returns the shared chDB client (a chdb Session).
57+
- ``query_executor``: thread pool to run blocking chDB work off the event loop.
58+
- ``query_timeout``: per-query timeout in seconds (also fed to ChDBTool's
59+
engine-side ``max_execution_time`` as defense in depth).
60+
- ``allow_write``: must match the session's mode. When False (default) the
61+
session must already be under ``SET readonly=2`` (the server applies it
62+
at init) so writes/DDL are rejected by the engine.
63+
"""
64+
from chdb.agents import ChDBError, ChDBTool
65+
66+
# One ChDBTool over the shared session. It probes (never mutates) the
67+
# session's readonly mode — construction fails with CONFIG_MISMATCH if the
68+
# flag disagrees with the session — and applies the byte cap and the
69+
# engine-side timeout.
70+
tool = ChDBTool(
71+
session=create_client(),
72+
read_only=not allow_write,
73+
max_bytes=max_result_bytes,
74+
max_execution_time=query_timeout(),
75+
)
76+
77+
async def _run(work: Callable[[], object], tool_name: str) -> str:
78+
"""Run a ChDBTool call on the executor with a timeout; return JSON text.
79+
80+
Output mirrors the ClickHouse-server tools: success is bare JSON, and any
81+
failure (engine error or timeout) is raised as a ``ToolError`` — the same
82+
way ``run_query`` surfaces errors — rather than returned as a string."""
83+
timeout = query_timeout()
84+
future = query_executor.submit(work)
85+
try:
86+
result = await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
87+
except asyncio.TimeoutError:
88+
future.cancel()
89+
logger.warning("chDB %s timed out after %ss", tool_name, timeout)
90+
raise ToolError(f"chDB {tool_name} timed out after {timeout} seconds")
91+
except ChDBError as err:
92+
raise ToolError(err.message)
93+
except Exception as err: # noqa: BLE001
94+
logger.error("chDB %s failed: %s", tool_name, err)
95+
raise ToolError(str(err))
96+
return json.dumps(result, ensure_ascii=False, default=str)
97+
98+
async def list_databases() -> str:
99+
"""List databases in the in-process chDB engine."""
100+
return await _run(tool.list_databases, "list_databases")
101+
102+
async def list_tables(database: str) -> str:
103+
"""List tables in a chDB database.
104+
105+
Args:
106+
database: Database name (plain SQL identifier).
107+
"""
108+
return await _run(lambda: tool.list_tables(database), "list_tables")
109+
110+
async def describe_table(database: str, table: str) -> str:
111+
"""Return column names and types for a chDB table.
112+
113+
Args:
114+
database: Database name (plain identifier).
115+
table: Table name (plain identifier).
116+
"""
117+
return await _run(lambda: tool.describe(table, database=database), "describe_table")
118+
119+
async def get_sample_data(database: str, table: str, limit: int = 10) -> str:
120+
"""Return the first rows of a chDB table.
121+
122+
Args:
123+
database: Database name (plain identifier).
124+
table: Table name (plain identifier).
125+
limit: Maximum rows to return; clamped to [1, 1000].
126+
"""
127+
n = max(1, min(int(limit), 1000))
128+
return await _run(
129+
lambda: tool.get_sample_data(table, database=database, limit=n).rows,
130+
"get_sample_data",
131+
)
132+
133+
async def list_functions(pattern: Optional[str] = None) -> str:
134+
"""List SQL functions available in the chDB engine.
135+
136+
Args:
137+
pattern: Optional case-insensitive substring filter on the function
138+
name.
139+
"""
140+
# `pattern` is a substring; ChDBTool's `like` is a raw LIKE pattern, so
141+
# wrap with %...% to preserve the substring-match semantics.
142+
like = f"%{pattern}%" if pattern else None
143+
return await _run(lambda: tool.list_functions(like=like), "list_functions")
144+
145+
tools = (
146+
(list_databases, "list_databases", "List databases in the in-process chDB engine."),
147+
(list_tables, "list_tables", "List tables in a chDB database."),
148+
(describe_table, "describe_table", "Return column names and types for a chDB table."),
149+
(get_sample_data, "get_sample_data", "Return the first rows of a chDB table."),
150+
(list_functions, "list_functions", "List SQL functions available in the chDB engine."),
151+
)
152+
for fn, name, description in tools:
153+
mcp.add_tool(Tool.from_function(fn, name=name, description=description))
154+
logger.info("chDB-only introspection tools registered (%d tools, over chdb.agents.ChDBTool)", len(tools))

mcp_clickhouse/mcp_env.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,20 @@ class ChDBConfig:
218218
219219
Required environment variables:
220220
CHDB_DATA_PATH: The path to the chDB data directory (only required if CHDB_ENABLED=true)
221+
222+
Optional environment variables (with defaults):
223+
CHDB_ALLOW_WRITE_ACCESS: Allow writes; when false, the session runs under
224+
SET readonly=2 (table functions stay usable) (default: false)
225+
CHDB_MAX_RESULT_BYTES: Engine result-size cap, also used as the final
226+
Python truncation budget (default: 1048576, i.e. 1 MiB)
227+
CHDB_FILE_ALLOWLIST: Colon-separated path prefixes. When set, the chDB
228+
query tool is sandboxed: raw SQL may not call external/file table
229+
functions (file/url/s3/remote/...) (default: unset)
221230
"""
222231

232+
# Engine result-size cap (1 MiB) when CHDB_MAX_RESULT_BYTES is unset/invalid.
233+
DEFAULT_MAX_RESULT_BYTES = 1024 * 1024
234+
223235
def __init__(self):
224236
"""Initialize the configuration from environment variables."""
225237
if self.enabled:
@@ -238,6 +250,49 @@ def data_path(self) -> str:
238250
"""Get the chDB data path."""
239251
return os.getenv("CHDB_DATA_PATH", ":memory:")
240252

253+
@property
254+
def allow_write_access(self) -> bool:
255+
"""Get whether writes are allowed.
256+
257+
When false (the default), the chDB session is put under SET readonly=2,
258+
which rejects INSERT/CREATE/DROP/ALTER on persistent tables while still
259+
allowing SET and table functions.
260+
261+
Default: False
262+
"""
263+
return os.getenv("CHDB_ALLOW_WRITE_ACCESS", "false").lower() == "true"
264+
265+
@property
266+
def max_result_bytes(self) -> int:
267+
"""Get the result-size cap in bytes (engine cap + Python truncation).
268+
269+
Falls back to DEFAULT_MAX_RESULT_BYTES when unset or not a positive int.
270+
271+
Default: 1048576 (1 MiB)
272+
"""
273+
raw = os.getenv("CHDB_MAX_RESULT_BYTES")
274+
if not raw:
275+
return self.DEFAULT_MAX_RESULT_BYTES
276+
try:
277+
value = int(raw)
278+
except ValueError:
279+
return self.DEFAULT_MAX_RESULT_BYTES
280+
return value if value > 0 else self.DEFAULT_MAX_RESULT_BYTES
281+
282+
@property
283+
def file_allowlist(self) -> tuple[str, ...]:
284+
"""Colon-separated file-path allowlist prefixes (like DuckDB's ``allowed_directories``).
285+
286+
When non-empty, chDB may read file-like sources (file/s3/url/…) only when
287+
their path is under one of these prefixes; paths outside — and DSN-based
288+
external sources that can't be path-checked — are refused. Empty by
289+
default (no restriction). See ``CHDB_FILE_ALLOWLIST``.
290+
"""
291+
raw = os.getenv("CHDB_FILE_ALLOWLIST")
292+
if not raw:
293+
return ()
294+
return tuple(p for p in (part.strip() for part in raw.split(":")) if p)
295+
241296
def get_client_config(self) -> dict:
242297
"""Get the configuration dictionary for chDB client.
243298

0 commit comments

Comments
 (0)