This is an automated email from the ASF dual-hosted git repository. cgivre pushed a commit to branch feat/drill-mcp-server in repository https://gitbox.apache.org/repos/asf/drill-mcp.git
commit 75ee75aa37126c7a564ccbd532ac9cfff0a83bf4 Author: cgivre <[email protected]> AuthorDate: Tue Aug 11 14:52:44 2026 -0400 Add implementation plan for the Drill MCP server Ten TDD tasks: config, SQL guard, redaction, REST client, JDBC client, tool layer, and wiring. Each task ends with a runnable test cycle. --- .../plans/2026-08-11-drill-mcp-server.md | 2633 ++++++++++++++++++++ 1 file changed, 2633 insertions(+) diff --git a/docs/superpowers/plans/2026-08-11-drill-mcp-server.md b/docs/superpowers/plans/2026-08-11-drill-mcp-server.md new file mode 100644 index 0000000..6fb01fa --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-drill-mcp-server.md @@ -0,0 +1,2633 @@ +# Apache Drill MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an MCP server that lets an LLM client explore and query an Apache Drill cluster safely — enumerate schemata, run SQL, read storage plugin and cluster state — with writes denied by default. + +**Architecture:** A Python package exposing one MCP server over stdio via FastMCP. A narrow `DrillClient` protocol has two implementations: `RestClient` (httpx against Drill's HTTP endpoints, the default) and `JdbcClient` (optional extra, exists mainly for Kerberos). All user-supplied SQL passes through a pure policy module, `guard.py`, before reaching any client. Tool bodies live on a plain `DrillTools` class so they can be unit-tested without standing up an MCP session. + +**Tech Stack:** Python 3.11+, `mcp` (FastMCP), `httpx`, `sqlglot`, `pydantic`, `PyYAML`. Optional: `jaydebeapi`/`JPype1`. Dev: `pytest`, `respx`, `pytest-cov`. + +**Spec:** `docs/superpowers/specs/2026-08-11-drill-mcp-design.md` + +## Global Constraints + +- Python 3.11 or newer. Use `X | None` union syntax, not `Optional[X]`. +- Storage plugin create/update/delete and `ALTER SYSTEM` are **never** implemented. There is no flag that enables them. Do not add the endpoints. +- Secret redaction is not configurable off. +- Credentials are read from config or environment only. No tool may accept a credential as an argument. +- Every public function gets unit tests. The full suite must run with no Drill cluster and no JVM. +- `guard.py` is pure — no I/O, no config loading, no network. It takes a string and a policy, and returns or raises. +- A parse failure in the guard is a **rejection**, never a pass-through. +- Commit after every task. Conventional-commit prefixes (`feat:`, `test:`, `chore:`). + +## File Structure + +| File | Responsibility | +|---|---| +| `pyproject.toml` | Packaging, dependencies, the `jdbc` extra, pytest config | +| `drill_mcp/__init__.py` | Version constant only | +| `drill_mcp/config.py` | `Config` model, file/env/CLI precedence, startup validation | +| `drill_mcp/guard.py` | `Policy`, `PolicyError`, `check()` — pure SQL policy | +| `drill_mcp/redact.py` | Recursive secret redaction | +| `drill_mcp/client_rest.py` | `RestClient` — auth, query, metadata, management | +| `drill_mcp/client_jdbc.py` | `JdbcClient` — query + metadata only | +| `drill_mcp/server.py` | `DrillTools` methods, `build_server`, `main()` entry point | +| `tests/test_*.py` | One test module per source module | + +Task order builds inward-out: config and the pure guard first (no dependencies, heaviest tests), then clients, then the tool layer that composes them. + +--- + +### Task 1: Project scaffolding and configuration + +**Files:** +- Create: `pyproject.toml`, `drill_mcp/__init__.py`, `drill_mcp/config.py`, `.gitignore` +- Test: `tests/test_config.py` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `drill_mcp.config.Config` — pydantic model with fields `url: str`, `backend: Literal["rest","jdbc"]`, `auth: Literal["none","basic","kerberos"]`, `user: str | None`, `password: str | None`, `max_rows: int`, `timeout_seconds: int`, `writable_plugins: list[str]`, `hidden_schemas: list[str]`, `jdbc_driver_path: str | None` + - `drill_mcp.config.load_config(path: str | None = None, env: Mapping[str, str] | None = None, overrides: dict | None = None) -> Config` + - `drill_mcp.config.ConfigError` — raised on any validation failure + +- [ ] **Step 1: Create the package skeleton** + +`pyproject.toml`: + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "drill-mcp" +version = "0.1.0" +description = "MCP server for Apache Drill" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [ + "mcp>=1.2.0", + "httpx>=0.27", + "sqlglot>=25.0", + "pydantic>=2.6", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +jdbc = ["jaydebeapi>=1.2.3", "JPype1>=1.5"] +kerberos = ["httpx-gssapi>=0.3"] +dev = ["pytest>=8.0", "respx>=0.21", "pytest-cov>=5.0"] + +[project.scripts] +drill-mcp = "drill_mcp.server:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["integration: requires a live Drill cluster (deselected by default)"] +addopts = "-m 'not integration'" +``` + +`drill_mcp/__init__.py`: + +```python +__version__ = "0.1.0" +``` + +`.gitignore`: + +``` +__pycache__/ +*.egg-info/ +.pytest_cache/ +.coverage +dist/ +build/ +.venv/ +``` + +- [ ] **Step 2: Write the failing config tests** + +`tests/test_config.py`: + +```python +import pytest + +from drill_mcp.config import Config, ConfigError, load_config + + +def test_defaults_are_conservative(): + cfg = load_config() + assert cfg.url == "http://localhost:8047" + assert cfg.backend == "rest" + assert cfg.auth == "none" + assert cfg.max_rows == 1000 + assert cfg.timeout_seconds == 60 + assert cfg.writable_plugins == [] + assert cfg.hidden_schemas == [] + + +def test_loads_from_yaml_file(tmp_path): + path = tmp_path / "drill.yaml" + path.write_text("url: http://drill:8047\nmax_rows: 50\nwritable_plugins: [dfs.tmp]\n") + cfg = load_config(str(path)) + assert cfg.url == "http://drill:8047" + assert cfg.max_rows == 50 + assert cfg.writable_plugins == ["dfs.tmp"] + + +def test_env_overrides_file(tmp_path): + path = tmp_path / "drill.yaml" + path.write_text("url: http://from-file:8047\n") + cfg = load_config(str(path), env={"DRILL_URL": "http://from-env:8047"}) + assert cfg.url == "http://from-env:8047" + + +def test_cli_overrides_env(tmp_path): + cfg = load_config( + env={"DRILL_URL": "http://from-env:8047"}, + overrides={"url": "http://from-cli:8047"}, + ) + assert cfg.url == "http://from-cli:8047" + + +def test_credentials_read_from_env(): + cfg = load_config(env={"DRILL_USER": "alice", "DRILL_PASSWORD": "s3cret", "DRILL_AUTH": "basic"}) + assert cfg.user == "alice" + assert cfg.password == "s3cret" + + +def test_unknown_key_is_an_error(tmp_path): + path = tmp_path / "drill.yaml" + path.write_text("uurl: http://typo:8047\n") + with pytest.raises(ConfigError, match="uurl"): + load_config(str(path)) + + +def test_basic_auth_requires_credentials(): + with pytest.raises(ConfigError, match="user"): + load_config(overrides={"auth": "basic"}) + + +def test_jdbc_backend_requires_driver_path(): + with pytest.raises(ConfigError, match="jdbc_driver_path"): + load_config(overrides={"backend": "jdbc"}) + + +def test_invalid_backend_is_an_error(): + with pytest.raises(ConfigError): + load_config(overrides={"backend": "carrier-pigeon"}) + + +def test_max_rows_must_be_positive(): + with pytest.raises(ConfigError): + load_config(overrides={"max_rows": 0}) + + +def test_missing_config_file_is_an_error(): + with pytest.raises(ConfigError, match="not found"): + load_config("/nonexistent/drill.yaml") + + +def test_config_is_immutable(): + cfg = load_config() + with pytest.raises(Exception): + cfg.url = "http://elsewhere:8047" +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `pytest tests/test_config.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'drill_mcp.config'` + +- [ ] **Step 4: Implement `drill_mcp/config.py`** + +```python +"""Configuration loading and validation. + +Precedence, later overriding earlier: config file, environment, CLI overrides. +Validation happens at startup, not at first tool call — a server that starts is +a server that is configured correctly. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + + +class ConfigError(Exception): + """Raised when configuration is missing, malformed, or internally inconsistent.""" + + +_ENV_MAP = { + "DRILL_URL": "url", + "DRILL_BACKEND": "backend", + "DRILL_AUTH": "auth", + "DRILL_USER": "user", + "DRILL_PASSWORD": "password", + "DRILL_MAX_ROWS": "max_rows", + "DRILL_TIMEOUT_SECONDS": "timeout_seconds", + "DRILL_JDBC_DRIVER_PATH": "jdbc_driver_path", +} + +_INT_FIELDS = {"max_rows", "timeout_seconds"} + + +class Config(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + url: str = "http://localhost:8047" + backend: Literal["rest", "jdbc"] = "rest" + auth: Literal["none", "basic", "kerberos"] = "none" + user: str | None = None + password: str | None = None + max_rows: int = Field(default=1000, gt=0) + timeout_seconds: int = Field(default=60, gt=0) + writable_plugins: list[str] = Field(default_factory=list) + hidden_schemas: list[str] = Field(default_factory=list) + jdbc_driver_path: str | None = None + + @model_validator(mode="after") + def _check_consistency(self) -> "Config": + if self.auth == "basic" and not (self.user and self.password): + raise ValueError("auth: basic requires both user and password") + if self.backend == "jdbc" and not self.jdbc_driver_path: + raise ValueError("backend: jdbc requires jdbc_driver_path") + return self + + +def load_config( + path: str | None = None, + env: Mapping[str, str] | None = None, + overrides: dict[str, Any] | None = None, +) -> Config: + env = os.environ if env is None else env + values: dict[str, Any] = {} + + if path is not None: + file_path = Path(path) + if not file_path.is_file(): + raise ConfigError(f"config file not found: {path}") + try: + loaded = yaml.safe_load(file_path.read_text()) or {} + except yaml.YAMLError as exc: + raise ConfigError(f"config file is not valid YAML: {exc}") from exc + if not isinstance(loaded, dict): + raise ConfigError("config file must contain a YAML mapping at the top level") + values.update(loaded) + + for env_key, field in _ENV_MAP.items(): + if env_key in env: + values[field] = env[env_key] + + values.update(overrides or {}) + + for field in _INT_FIELDS: + if isinstance(values.get(field), str): + try: + values[field] = int(values[field]) + except ValueError as exc: + raise ConfigError(f"{field} must be an integer") from exc + + try: + return Config(**values) + except ValidationError as exc: + raise ConfigError(str(exc)) from exc +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pytest tests/test_config.py -v` +Expected: PASS, 12 tests + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml .gitignore drill_mcp/ tests/ +git commit -m "feat: package skeleton and configuration loading" +``` + +--- + +### Task 2: SQL guard — statement classification and write policy + +**Files:** +- Create: `drill_mcp/guard.py` +- Test: `tests/test_guard.py` + +**Interfaces:** +- Consumes: nothing (`guard.py` is pure and standalone) +- Produces: + - `drill_mcp.guard.Policy` — frozen dataclass, `writable_plugins: tuple[str, ...] = ()`, `hidden_schemas: tuple[str, ...] = ()`, plus classmethod `from_config(cfg) -> Policy` + - `drill_mcp.guard.PolicyError(Exception)` + - `drill_mcp.guard.check(sql: str, policy: Policy) -> None` — returns `None` if permitted, raises `PolicyError` otherwise + - `drill_mcp.guard.matches_prefix(qualified: str, entries: Iterable[str]) -> bool` + +**Background for the implementer:** Drill's SQL is Apache Calcite–based. `sqlglot` has no Drill dialect; the Postgres dialect is the closest fit and is what this module uses. `sqlglot.parse()` returns a *list* of statements, which is how statement-stacking is detected. Constructs sqlglot does not recognize become `exp.Command`, a catch-all node holding the leading keyword and the raw remainder — so `exp.Command` must never be blanket-allowed. + +- [ ] **Step 1: Write a characterization test for sqlglot's parse tree** + +This test documents what sqlglot actually does, so the rest of the module can rely on it. Run it first and **fix the assertions to match observed reality** if your `sqlglot` version differs — then build the guard against the corrected facts. + +`tests/test_guard.py` (first block): + +```python +import pytest +import sqlglot +from sqlglot import exp + +from drill_mcp.guard import Policy, PolicyError, check, matches_prefix + + +class TestSqlglotAssumptions: + """Characterization tests: what the guard relies on sqlglot doing.""" + + def test_parse_returns_one_statement_per_semicolon(self): + assert len(sqlglot.parse("SELECT 1; SELECT 2", read="postgres")) == 2 + + def test_select_parses_to_select(self): + stmt = sqlglot.parse_one("SELECT * FROM dfs.tmp.foo", read="postgres") + assert isinstance(stmt, exp.Select) + + def test_table_exposes_catalog_db_name(self): + table = sqlglot.parse_one("SELECT * FROM dfs.tmp.foo", read="postgres").find(exp.Table) + assert table.catalog == "dfs" + assert table.db == "tmp" + assert table.name == "foo" + + def test_two_part_name_populates_db_not_catalog(self): + table = sqlglot.parse_one("SELECT * FROM sys.options", read="postgres").find(exp.Table) + assert table.catalog == "" + assert table.db == "sys" + assert table.name == "options" + + def test_comments_are_stripped_by_the_tokenizer(self): + stmt = sqlglot.parse_one("-- CREATE TABLE evil\nSELECT 1", read="postgres") + assert isinstance(stmt, exp.Select) + + def test_ctas_target_is_reachable_from_this(self): + stmt = sqlglot.parse_one( + "CREATE TABLE dfs.tmp.out AS SELECT * FROM dfs.raw.src", read="postgres" + ) + assert isinstance(stmt, exp.Create) + target = stmt.this.this if isinstance(stmt.this, exp.Schema) else stmt.this + assert isinstance(target, exp.Table) + assert target.catalog == "dfs" + assert target.db == "tmp" +``` + +- [ ] **Step 2: Run the characterization test** + +Run: `pytest tests/test_guard.py::TestSqlglotAssumptions -v` +Expected: FAIL on the import line — `ModuleNotFoundError: No module named 'drill_mcp.guard'`. + +Temporarily comment out the `from drill_mcp.guard import ...` line and re-run to confirm all six assumptions hold against your installed `sqlglot`. Correct any assertion that does not match, then restore the import. + +- [ ] **Step 3: Write the failing write-policy tests** + +Append to `tests/test_guard.py`: + +```python +OPEN = Policy(writable_plugins=("dfs.tmp",)) +CLOSED = Policy() + + +class TestReadsAreAllowed: + @pytest.mark.parametrize( + "sql", + [ + "SELECT 1", + "SELECT * FROM dfs.tmp.foo", + "SELECT a, b FROM dfs.tmp.foo WHERE a > 1 ORDER BY b", + "WITH x AS (SELECT * FROM dfs.tmp.foo) SELECT * FROM x", + "SELECT * FROM dfs.tmp.a UNION SELECT * FROM dfs.tmp.b", + "SELECT * FROM dfs.tmp.a JOIN dfs.tmp.b ON a.id = b.id", + "SHOW SCHEMAS", + "SHOW TABLES", + "DESCRIBE dfs.tmp.foo", + ], + ) + def test_permitted(self, sql): + check(sql, CLOSED) # does not raise + + +class TestWritesAreDeniedByDefault: + @pytest.mark.parametrize( + "sql", + [ + "CREATE TABLE dfs.tmp.out AS SELECT * FROM dfs.tmp.src", + "CREATE VIEW dfs.tmp.v AS SELECT 1", + "DROP TABLE dfs.tmp.foo", + "DROP VIEW dfs.tmp.v", + "INSERT INTO dfs.tmp.foo VALUES (1)", + "ALTER SYSTEM SET `planner.width.max_per_node` = 4", + "ALTER SESSION SET `store.format` = 'json'", + "USE dfs.tmp", + "REFRESH TABLE METADATA dfs.tmp.foo", + ], + ) + def test_rejected_with_no_writable_plugins(self, sql): + with pytest.raises(PolicyError): + check(sql, CLOSED) + + +class TestWritesWithAnAllowlist: + def test_ctas_into_allowed_plugin_permitted(self): + check("CREATE TABLE dfs.tmp.out AS SELECT * FROM dfs.raw.src", OPEN) + + def test_create_view_into_allowed_plugin_permitted(self): + check("CREATE VIEW dfs.tmp.v AS SELECT 1", OPEN) + + def test_drop_in_allowed_plugin_permitted(self): + check("DROP TABLE dfs.tmp.old", OPEN) + + def test_ctas_into_other_plugin_rejected(self): + with pytest.raises(PolicyError, match="writable_plugins"): + check("CREATE TABLE s3.bucket.out AS SELECT 1", OPEN) + + def test_sibling_workspace_rejected(self): + with pytest.raises(PolicyError): + check("CREATE TABLE dfs.raw.out AS SELECT 1", OPEN) + + def test_plugin_level_entry_permits_any_workspace(self): + check("CREATE TABLE dfs.raw.out AS SELECT 1", Policy(writable_plugins=("dfs",))) + + def test_matching_is_case_insensitive(self): + check("CREATE TABLE DFS.TMP.OUT AS SELECT 1", OPEN) + + def test_insert_still_rejected_even_when_plugin_writable(self): + with pytest.raises(PolicyError): + check("INSERT INTO dfs.tmp.foo VALUES (1)", OPEN) + + def test_unqualified_target_rejected(self): + with pytest.raises(PolicyError): + check("CREATE TABLE bare AS SELECT 1", OPEN) + + +class TestInjectionAttempts: + def test_write_hidden_in_a_comment_is_not_a_write(self): + check("-- CREATE TABLE dfs.tmp.x AS SELECT 1\nSELECT 1", CLOSED) + + def test_write_inside_a_string_literal_is_not_a_write(self): + check("SELECT 'DROP TABLE dfs.tmp.foo' AS note", CLOSED) + + def test_stacked_statements_rejected(self): + with pytest.raises(PolicyError, match="one statement"): + check("SELECT 1; DROP TABLE dfs.tmp.foo", CLOSED) + + def test_stacked_statements_rejected_even_when_both_are_reads(self): + with pytest.raises(PolicyError, match="one statement"): + check("SELECT 1; SELECT 2", CLOSED) + + def test_trailing_semicolon_is_fine(self): + check("SELECT 1;", CLOSED) + + def test_empty_sql_rejected(self): + with pytest.raises(PolicyError): + check(" ", CLOSED) + + def test_unparseable_sql_rejected(self): + with pytest.raises(PolicyError, match="parse"): + check("SELECT FROM WHERE ((", CLOSED) + + +class TestMatchesPrefix: + def test_exact_match(self): + assert matches_prefix("dfs.tmp", ["dfs.tmp"]) + + def test_parent_entry_matches_child(self): + assert matches_prefix("dfs.tmp", ["dfs"]) + + def test_child_entry_does_not_match_parent(self): + assert not matches_prefix("dfs", ["dfs.tmp"]) + + def test_sibling_does_not_match(self): + assert not matches_prefix("dfs.raw", ["dfs.tmp"]) + + def test_case_insensitive(self): + assert matches_prefix("DFS.TMP", ["dfs.tmp"]) + + def test_no_partial_component_match(self): + assert not matches_prefix("dfsx.tmp", ["dfs"]) + + def test_empty_entries_never_match(self): + assert not matches_prefix("dfs.tmp", []) +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `pytest tests/test_guard.py -v` +Expected: FAIL — `ImportError: cannot import name 'Policy' from 'drill_mcp.guard'` + +- [ ] **Step 5: Implement `drill_mcp/guard.py`** + +```python +"""SQL policy enforcement. Pure: string in, decision out, no I/O. + +Deny by default. Reads are permitted; a write is permitted only when its target +resolves into a plugin the operator explicitly listed in `writable_plugins`. + +A real parser rather than a regex, deliberately: a regex guard is defeated by +`-- CREATE TABLE` in a comment, by `'DROP TABLE'` inside a string literal, and +by statement stacking. This module is the only thing standing between a model +and the user's data. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass + +import sqlglot +from sqlglot import exp + +DIALECT = "postgres" # closest available fit for Drill's Calcite SQL + +# Commands sqlglot does not model as expressions, but which cannot write. +_SAFE_COMMANDS = {"SHOW", "DESCRIBE", "DESC", "EXPLAIN"} + +_READ_TYPES = (exp.Select, exp.Union, exp.Intersect, exp.Except, exp.Subquery, exp.Describe) + + +class PolicyError(Exception): + """Raised when a statement is not permitted. The message is shown to the caller.""" + + +@dataclass(frozen=True) +class Policy: + writable_plugins: tuple[str, ...] = () + hidden_schemas: tuple[str, ...] = () + + @classmethod + def from_config(cls, cfg) -> "Policy": + return cls( + writable_plugins=tuple(cfg.writable_plugins), + hidden_schemas=tuple(cfg.hidden_schemas), + ) + + +def matches_prefix(qualified: str, entries: Iterable[str]) -> bool: + """True if any entry is a dotted-component prefix of `qualified`. + + Component-wise so that `dfs` matches `dfs.tmp` but not `dfsx.tmp`. + """ + parts = [p.lower() for p in qualified.split(".") if p] + if not parts: + return False + for entry in entries: + entry_parts = [p.lower() for p in entry.split(".") if p] + if entry_parts and parts[: len(entry_parts)] == entry_parts: + return True + return False + + +def check(sql: str, policy: Policy) -> None: + """Return None if `sql` is permitted under `policy`; raise PolicyError otherwise.""" + if not sql or not sql.strip(): + raise PolicyError("empty SQL statement") + + try: + statements = [s for s in sqlglot.parse(sql, read=DIALECT) if s is not None] + except sqlglot.ParseError as exc: + raise PolicyError( + f"could not parse SQL, so it cannot be checked against policy and is rejected: {exc}" + ) from exc + + if len(statements) != 1: + raise PolicyError( + f"exactly one statement per call is permitted, got {len(statements)}" + ) + + statement = statements[0] + _check_hidden(statement, policy) + _check_write(statement, policy) + + +def _check_write(statement: exp.Expression, policy: Policy) -> None: + if isinstance(statement, _READ_TYPES): + return + + if isinstance(statement, exp.Command): + keyword = str(statement.this or "").upper() + if keyword in _SAFE_COMMANDS: + return + raise PolicyError(f"statement type {keyword or 'UNKNOWN'} is not permitted") + + if isinstance(statement, (exp.Create, exp.Drop)): + kind = (statement.args.get("kind") or "").upper() + if kind not in {"TABLE", "VIEW"}: + raise PolicyError(f"{statement.key.upper()} {kind or 'UNKNOWN'} is not permitted") + target = _write_target(statement) + if target is None: + raise PolicyError("could not determine the target of this statement, rejecting") + qualified = _schema_prefix(target) + if not qualified: + raise PolicyError( + f"write target '{target.name}' is not schema-qualified; " + "qualify it with a plugin listed in writable_plugins" + ) + if not matches_prefix(qualified, policy.writable_plugins): + raise PolicyError( + f"writes to '{qualified}' are not permitted; " + f"add it to writable_plugins to allow this (currently: " + f"{list(policy.writable_plugins) or 'none'})" + ) + return + + raise PolicyError(f"statement type {statement.key.upper()} is not permitted") + + +def _write_target(statement: exp.Expression) -> exp.Table | None: + target = statement.this + if isinstance(target, exp.Schema): + target = target.this + return target if isinstance(target, exp.Table) else None + + +def _schema_prefix(table: exp.Table) -> str: + return ".".join(part for part in (table.catalog, table.db) if part) + + +def _check_hidden(statement: exp.Expression, policy: Policy) -> None: + if not policy.hidden_schemas: + return + + for table in statement.find_all(exp.Table): + prefix = _schema_prefix(table) + if prefix and matches_prefix(prefix, policy.hidden_schemas): + raise PolicyError(f"schema '{prefix}' is hidden by configuration") + + if isinstance(statement, exp.Command): + # ponytail: sqlglot leaves SHOW's remainder as raw text, so scan it for + # hidden names. Safe because the tokenizer already stripped comments and + # string literals cannot appear in a SHOW target. Upgrade to a real parse + # if Drill's SHOW grammar ever gains an expression argument. + remainder = str(statement.args.get("expression") or "") + for entry in policy.hidden_schemas: + head = entry.split(".")[0] + if re.search(rf"\b{re.escape(head)}\b", remainder, re.IGNORECASE): + raise PolicyError(f"schema '{entry}' is hidden by configuration") +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pytest tests/test_guard.py -v` +Expected: PASS + +If `SHOW SCHEMAS` or `DESCRIBE dfs.tmp.foo` fails to parse in your `sqlglot` version, the fix is in `_SAFE_COMMANDS` / `_READ_TYPES` — do **not** loosen `exp.Command` handling to a blanket allow. + +- [ ] **Step 7: Commit** + +```bash +git add drill_mcp/guard.py tests/test_guard.py +git commit -m "feat: SQL guard with deny-by-default write policy" +``` + +--- + +### Task 3: SQL guard — hidden schemas + +**Files:** +- Modify: `drill_mcp/guard.py` (only if the Task 2 tests reveal gaps — `_check_hidden` is already written) +- Test: `tests/test_guard.py` (append) + +**Interfaces:** +- Consumes: `guard.Policy`, `guard.check`, `guard.PolicyError` from Task 2 +- Produces: no new symbols; hardens existing behavior + +- [ ] **Step 1: Write the failing hidden-schema tests** + +Append to `tests/test_guard.py`: + +```python +HIDDEN = Policy(hidden_schemas=("sys", "INFORMATION_SCHEMA")) + + +class TestHiddenSchemas: + @pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM sys.options", + "SELECT * FROM sys.drillbits", + "SELECT * FROM SYS.OPTIONS", + "SELECT * FROM INFORMATION_SCHEMA.SCHEMATA", + "SELECT * FROM information_schema.columns", + "SELECT * FROM dfs.tmp.a JOIN sys.options ON a.k = sys.options.name", + "SELECT * FROM (SELECT name FROM sys.options) t", + "WITH o AS (SELECT * FROM sys.options) SELECT * FROM o", + "SELECT * FROM dfs.tmp.a UNION SELECT name, val FROM sys.options", + "SELECT (SELECT count(*) FROM sys.drillbits) AS n FROM dfs.tmp.a", + ], + ) + def test_hidden_schema_references_rejected(self, sql): + with pytest.raises(PolicyError, match="hidden"): + check(sql, HIDDEN) + + def test_show_tables_in_hidden_schema_rejected(self): + with pytest.raises(PolicyError, match="hidden"): + check("SHOW TABLES IN sys", HIDDEN) + + def test_hidden_schema_is_a_no_op_when_unconfigured(self): + check("SELECT * FROM sys.options", CLOSED) + + def test_non_hidden_schemas_still_permitted(self): + check("SELECT * FROM dfs.tmp.foo", HIDDEN) + + def test_similarly_named_schema_not_blocked(self): + check("SELECT * FROM system_metrics.foo", HIDDEN) + + def test_hidden_check_applies_to_ctas_sources(self): + policy = Policy(writable_plugins=("dfs.tmp",), hidden_schemas=("sys",)) + with pytest.raises(PolicyError, match="hidden"): + check("CREATE TABLE dfs.tmp.out AS SELECT * FROM sys.options", policy) + + def test_hidden_schema_named_in_a_string_literal_is_fine(self): + check("SELECT 'sys.options' AS note FROM dfs.tmp.a", HIDDEN) +``` + +- [ ] **Step 2: Run the tests** + +Run: `pytest tests/test_guard.py -k Hidden -v` +Expected: most PASS from Task 2's implementation. Any FAIL is a real gap — fix it in `_check_hidden` before continuing. + +Two failures are plausible and both must be fixed rather than accommodated: +- `test_similarly_named_schema_not_blocked` failing means the `\b` regex in the `exp.Command` branch is leaking into non-`SHOW` paths. It should only run for `exp.Command`. +- `test_hidden_schema_named_in_a_string_literal_is_fine` failing means something is scanning raw text where it should be walking `exp.Table` nodes. + +- [ ] **Step 3: Verify the full guard suite passes together** + +Run: `pytest tests/test_guard.py -v --cov=drill_mcp.guard --cov-report=term-missing` +Expected: PASS, with `guard.py` at 100% line coverage. Add a test for any uncovered line. + +- [ ] **Step 4: Commit** + +```bash +git add drill_mcp/guard.py tests/test_guard.py +git commit -m "test: hidden schema enforcement in the SQL guard" +``` + +--- + +### Task 4: Secret redaction + +**Files:** +- Create: `drill_mcp/redact.py` +- Test: `tests/test_redact.py` + +**Interfaces:** +- Consumes: nothing +- Produces: `drill_mcp.redact.redact(value: Any) -> Any` and `drill_mcp.redact.REDACTED: str` + +- [ ] **Step 1: Write the failing tests** + +`tests/test_redact.py`: + +```python +from drill_mcp.redact import REDACTED, redact + + +def test_redacts_password_key(): + assert redact({"password": "hunter2"}) == {"password": REDACTED} + + +def test_redaction_is_case_insensitive(): + assert redact({"PassWord": "hunter2"}) == {"PassWord": REDACTED} + + +def test_redacts_all_sensitive_key_patterns(): + source = { + "accessKey": "AKIA", + "access_key": "AKIA", + "secretKey": "s", + "token": "t", + "credential": "c", + "privateKey": "p", + "oauthToken": "o", + } + assert all(v == REDACTED for v in redact(source).values()) + + +def test_leaves_innocuous_keys_alone(): + assert redact({"type": "file", "connection": "s3a://bucket"}) == { + "type": "file", + "connection": "s3a://bucket", + } + + +def test_recurses_into_nested_dicts(): + source = {"config": {"credentialsProvider": {"awsSecretAccessKey": "s"}}} + assert redact(source)["config"]["credentialsProvider"]["awsSecretAccessKey"] == REDACTED + + +def test_recurses_into_lists(): + source = {"plugins": [{"password": "a"}, {"password": "b"}]} + assert [p["password"] for p in redact(source)["plugins"]] == [REDACTED, REDACTED] + + +def test_does_not_mutate_the_input(): + source = {"password": "hunter2"} + redact(source) + assert source["password"] == "hunter2" + + +def test_redacts_whole_subtree_under_a_sensitive_key(): + source = {"credentials": {"user": "alice", "pass": "x"}} + assert redact(source)["credentials"] == REDACTED + + +def test_passes_through_scalars(): + assert redact("plain") == "plain" + assert redact(42) == 42 + assert redact(None) is None + + +def test_realistic_s3_plugin_config(): + plugin = { + "name": "s3", + "config": { + "type": "file", + "connection": "s3a://my-bucket", + "config": { + "fs.s3a.access.key": "AKIAEXAMPLE", + "fs.s3a.secret.key": "verysecret", + "fs.s3a.endpoint": "s3.amazonaws.com", + }, + "workspaces": {"root": {"location": "/", "writable": False}}, + }, + } + result = redact(plugin) + inner = result["config"]["config"] + assert inner["fs.s3a.access.key"] == REDACTED + assert inner["fs.s3a.secret.key"] == REDACTED + assert inner["fs.s3a.endpoint"] == "s3.amazonaws.com" + assert result["config"]["workspaces"]["root"]["location"] == "/" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_redact.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'drill_mcp.redact'` + +- [ ] **Step 3: Implement `drill_mcp/redact.py`** + +```python +"""Recursive secret redaction for anything returned to an MCP client. + +Storage plugin configurations routinely carry AWS keys, JDBC passwords, and +OAuth tokens. Tool output goes to a model and often on to a third-party API, so +this is a trust boundary and is not configurable off. +""" + +from __future__ import annotations + +import re +from typing import Any + +REDACTED = "***REDACTED***" + +# Matches anywhere in the key, so `fs.s3a.secret.key` and `awsSecretAccessKey` +# are both caught. Deliberately broad: a false redaction is a cosmetic problem, +# a missed one is a leaked credential. +_SENSITIVE = re.compile( + r"password|passwd|secret|credential|token|access[._-]?key|private[._-]?key|api[._-]?key", + re.IGNORECASE, +) + + +def redact(value: Any) -> Any: + """Return a copy of `value` with sensitive-looking values replaced.""" + if isinstance(value, dict): + return { + key: REDACTED if _SENSITIVE.search(str(key)) else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, tuple): + return tuple(redact(item) for item in value) + return value +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_redact.py -v` +Expected: PASS, 10 tests + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/redact.py tests/test_redact.py +git commit -m "feat: recursive secret redaction" +``` + +--- + +### Task 5: REST client — authentication and query execution + +**Files:** +- Create: `drill_mcp/client_rest.py` +- Test: `tests/test_client_rest.py` + +**Interfaces:** +- Consumes: `config.Config` +- Produces: + - `drill_mcp.client_rest.RestClient(config: Config)` with `close() -> None` + - `RestClient.query(sql: str, max_rows: int) -> QueryResult` + - `drill_mcp.client_rest.QueryResult` — dataclass with `columns: list[str]`, `rows: list[dict]`, `query_id: str | None`, `truncated: bool` + - `drill_mcp.client_rest.DrillError(Exception)` + - `drill_mcp.client_rest.quote_literal(value: str) -> str` — validates a single identifier and returns it as a SQL literal + - `drill_mcp.client_rest.quote_literal_path(value: str) -> str` — same, for a dotted schema path like `dfs.tmp` + +- [ ] **Step 1: Write the failing tests** + +`tests/test_client_rest.py`: + +```python +import httpx +import pytest +import respx + +from drill_mcp.client_rest import DrillError, RestClient, quote_literal, quote_literal_path +from drill_mcp.config import load_config + +BASE = "http://drill:8047" + + +def make_client(**overrides): + overrides.setdefault("url", BASE) + return RestClient(load_config(overrides=overrides)) + + +class TestQuoting: + """Trust boundary: schema and table names arrive from the model.""" + + def test_literal_quotes_a_single_identifier(self): + assert quote_literal("foo") == "'foo'" + + def test_literal_path_allows_dots(self): + assert quote_literal_path("dfs.tmp") == "'dfs.tmp'" + + def test_literal_path_allows_ordinary_drill_names(self): + assert quote_literal_path("my_ws.data-2024") == "'my_ws.data-2024'" + + @pytest.mark.parametrize( + "bad", + ["foo'bar", "foo;DROP", "foo bar", "foo\nbar", "", "foo\\bar", "foo`bar", "dfs.tmp"], + ) + def test_literal_rejects_dangerous_input(self, bad): + with pytest.raises(DrillError, match="invalid identifier"): + quote_literal(bad) + + @pytest.mark.parametrize( + "bad", + ["foo'bar", "foo;DROP", "foo bar", "foo\nbar", "", "..", "foo\\bar", "foo`bar"], + ) + def test_literal_path_rejects_dangerous_input(self, bad): + with pytest.raises(DrillError, match="invalid identifier"): + quote_literal_path(bad) + + +class TestQuery: + @respx.mock + def test_posts_sql_with_autolimit(self): + route = respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response( + 200, json={"columns": ["a"], "rows": [{"a": "1"}], "queryId": "q1"} + ) + ) + result = make_client().query("SELECT 1", max_rows=10) + assert route.called + body = route.calls.last.request.read() + assert b'"queryType": "SQL"' in body or b'"queryType":"SQL"' in body + assert b"autoLimit" in body + assert result.columns == ["a"] + assert result.rows == [{"a": "1"}] + assert result.query_id == "q1" + assert result.truncated is False + + @respx.mock + def test_marks_result_truncated_at_the_cap(self): + respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response( + 200, json={"columns": ["a"], "rows": [{"a": "1"}, {"a": "2"}]} + ) + ) + assert make_client().query("SELECT 1", max_rows=2).truncated is True + + @respx.mock + def test_drill_error_text_is_surfaced(self): + respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response(500, json={"errorMessage": "VALIDATION ERROR: no such table"}) + ) + with pytest.raises(DrillError, match="no such table"): + make_client().query("SELECT * FROM nope", max_rows=10) + + @respx.mock + def test_connection_failure_message_names_the_url_not_the_password(self): + respx.post(f"{BASE}/query.json").mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(DrillError) as exc: + make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + assert BASE in str(exc.value) + assert "s3cret" not in str(exc.value) + + @respx.mock + def test_timeout_is_reported_clearly(self): + respx.post(f"{BASE}/query.json").mock(side_effect=httpx.ReadTimeout("slow")) + with pytest.raises(DrillError, match="timed out"): + make_client().query("SELECT 1", max_rows=1) + + +class TestBasicAuth: + @respx.mock + def test_logs_in_before_the_first_query(self): + login = respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(200)) + respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response(200, json={"columns": [], "rows": []}) + ) + make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + assert login.called + assert b"j_username=alice" in login.calls.last.request.read() + + @respx.mock + def test_session_is_reused_across_queries(self): + login = respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(200)) + respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response(200, json={"columns": [], "rows": []}) + ) + client = make_client(auth="basic", user="alice", password="s3cret") + client.query("SELECT 1", max_rows=1) + client.query("SELECT 2", max_rows=1) + assert login.call_count == 1 + + @respx.mock + def test_reauthenticates_once_on_401(self): + login = respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(200)) + query = respx.post(f"{BASE}/query.json").mock( + side_effect=[ + httpx.Response(401), + httpx.Response(200, json={"columns": ["a"], "rows": []}), + ] + ) + result = make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + assert result.columns == ["a"] + assert login.call_count == 2 + assert query.call_count == 2 + + @respx.mock + def test_gives_up_after_one_retry(self): + respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(200)) + respx.post(f"{BASE}/query.json").mock(return_value=httpx.Response(401)) + with pytest.raises(DrillError, match="authentication"): + make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + + @respx.mock + def test_login_failure_is_reported(self): + respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(401)) + with pytest.raises(DrillError, match="authentication"): + make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + + @respx.mock + def test_no_login_when_auth_is_none(self): + login = respx.post(f"{BASE}/j_security_check").mock(return_value=httpx.Response(200)) + respx.post(f"{BASE}/query.json").mock( + return_value=httpx.Response(200, json={"columns": [], "rows": []}) + ) + make_client().query("SELECT 1", max_rows=1) + assert not login.called +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_client_rest.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'drill_mcp.client_rest'` + +- [ ] **Step 3: Implement the auth and query half of `drill_mcp/client_rest.py`** + +```python +"""Drill REST backend. + +Talks to a Drillbit's HTTP endpoints. Metadata methods issue INFORMATION_SCHEMA +queries directly and deliberately bypass `guard.py` — the guard governs SQL that +originated from the model, not SQL this module composes itself. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +import httpx + +from .config import Config + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9_$-]+$") + + +class DrillError(Exception): + """Any failure talking to Drill: connection, auth, or query error.""" + + +@dataclass +class QueryResult: + columns: list[str] = field(default_factory=list) + rows: list[dict[str, Any]] = field(default_factory=list) + query_id: str | None = None + truncated: bool = False + + +def quote_literal(value: str) -> str: + """Return one identifier as a single-quoted SQL literal, rejecting unsafe input. + + Trust boundary: schema and table names arrive from the model and are + interpolated into INFORMATION_SCHEMA queries. Drill's REST API has no bind + parameters, so anything outside the safe character set is rejected rather + than escaped. + """ + if not _IDENTIFIER.match(value): + raise DrillError(f"invalid identifier: {value!r}") + return f"'{value}'" + + +def quote_literal_path(value: str) -> str: + """Same as `quote_literal`, but permits a dotted schema path like `dfs.tmp`.""" + parts = value.split(".") + if not parts or any(not _IDENTIFIER.match(part) for part in parts): + raise DrillError(f"invalid identifier: {value!r}") + return f"'{value}'" + + +class RestClient: + def __init__(self, config: Config) -> None: + self._config = config + self._authenticated = False + auth = None + if config.auth == "kerberos": + try: + from httpx_gssapi import HTTPSPNEGOAuth + except ImportError as exc: # pragma: no cover - exercised in Task 7 style + raise DrillError( + "auth: kerberos requires the kerberos extra: pip install drill-mcp[kerberos]" + ) from exc + auth = HTTPSPNEGOAuth() + self._http = httpx.Client( + base_url=config.url, + timeout=config.timeout_seconds, + auth=auth, + follow_redirects=True, + ) + + def close(self) -> None: + self._http.close() + + # -- transport --------------------------------------------------------- + + def _login(self) -> None: + if self._config.auth != "basic": + self._authenticated = True + return + try: + response = self._http.post( + "/j_security_check", + data={ + "j_username": self._config.user, + "j_password": self._config.password, + }, + ) + except httpx.HTTPError as exc: + raise self._transport_error(exc) from exc + if response.status_code >= 400 or "j_security_check" in str(response.url): + raise DrillError( + f"authentication failed for user {self._config.user!r} at {self._config.url}" + ) + self._authenticated = True + + def _transport_error(self, exc: httpx.HTTPError) -> DrillError: + if isinstance(exc, httpx.TimeoutException): + return DrillError( + f"request to {self._config.url} timed out after " + f"{self._config.timeout_seconds}s" + ) + return DrillError( + f"could not reach Drill at {self._config.url} " + f"(auth mode: {self._config.auth}): {type(exc).__name__}" + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + if not self._authenticated: + self._login() + try: + response = self._http.request(method, path, **kwargs) + if response.status_code == 401 and self._config.auth == "basic": + self._authenticated = False + self._login() + response = self._http.request(method, path, **kwargs) + except httpx.HTTPError as exc: + raise self._transport_error(exc) from exc + + if response.status_code == 401: + raise DrillError( + f"authentication rejected by Drill at {self._config.url} " + f"for user {self._config.user!r}" + ) + if response.status_code >= 400: + raise DrillError(_error_text(response)) + return response + + # -- queries ----------------------------------------------------------- + + def query(self, sql: str, max_rows: int) -> QueryResult: + response = self._request( + "POST", + "/query.json", + json={"queryType": "SQL", "query": sql, "autoLimit": max_rows}, + ) + payload = response.json() + rows = payload.get("rows") or [] + return QueryResult( + columns=payload.get("columns") or [], + rows=rows, + query_id=payload.get("queryId"), + truncated=len(rows) >= max_rows, + ) + + +def _error_text(response: httpx.Response) -> str: + """Drill's own error text is what a model needs to fix its SQL. Truncate it.""" + try: + payload = response.json() + except ValueError: + payload = None + message = "" + if isinstance(payload, dict): + message = payload.get("errorMessage") or payload.get("message") or "" + if not message: + message = response.text + message = " ".join(message.split()) + if len(message) > 2000: + message = message[:2000] + " ... [truncated]" + return message or f"Drill returned HTTP {response.status_code}" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_client_rest.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/client_rest.py tests/test_client_rest.py +git commit -m "feat: Drill REST client with auth and query execution" +``` + +--- + +### Task 6: REST client — metadata and management endpoints + +**Files:** +- Modify: `drill_mcp/client_rest.py` +- Test: `tests/test_client_rest.py` (append) + +**Interfaces:** +- Consumes: `RestClient`, `QueryResult`, `DrillError`, `quote_literal`, `quote_literal_path` from Task 5; `redact.redact` from Task 4 +- Produces, all on `RestClient`: + - `schemas() -> list[dict]` — keys `name`, `type` + - `tables(schema: str) -> list[dict]` — keys `name`, `type` + - `columns(schema: str, table: str) -> list[dict]` — keys `name`, `data_type`, `nullable` + - `storage_plugins() -> list[dict]` — redacted + - `cluster_status() -> dict` + - `profiles(limit: int) -> list[dict]` + - `profile(query_id: str) -> dict` + - `cancel_query(query_id: str) -> str` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_client_rest.py`: + +```python +def query_response(columns, rows): + return httpx.Response(200, json={"columns": columns, "rows": rows, "queryId": "q"}) + + +class TestMetadata: + @respx.mock + def test_schemas_queries_information_schema(self): + route = respx.post(f"{BASE}/query.json").mock( + return_value=query_response( + ["SCHEMA_NAME", "TYPE"], [{"SCHEMA_NAME": "dfs.tmp", "TYPE": "file"}] + ) + ) + assert make_client().schemas() == [{"name": "dfs.tmp", "type": "file"}] + assert b"INFORMATION_SCHEMA" in route.calls.last.request.read() + + @respx.mock + def test_tables_filters_by_schema(self): + route = respx.post(f"{BASE}/query.json").mock( + return_value=query_response( + ["TABLE_NAME", "TABLE_TYPE"], [{"TABLE_NAME": "t", "TABLE_TYPE": "TABLE"}] + ) + ) + assert make_client().tables("dfs.tmp") == [{"name": "t", "type": "TABLE"}] + assert b"'dfs.tmp'" in route.calls.last.request.read() + + @respx.mock + def test_columns_returns_name_type_nullable(self): + respx.post(f"{BASE}/query.json").mock( + return_value=query_response( + ["COLUMN_NAME", "DATA_TYPE", "IS_NULLABLE"], + [{"COLUMN_NAME": "id", "DATA_TYPE": "INTEGER", "IS_NULLABLE": "YES"}], + ) + ) + assert make_client().columns("dfs.tmp", "t") == [ + {"name": "id", "data_type": "INTEGER", "nullable": True} + ] + + @respx.mock + def test_metadata_rejects_injection_in_schema_name(self): + with pytest.raises(DrillError, match="invalid identifier"): + make_client().tables("dfs'; DROP TABLE x --") + + @respx.mock + def test_metadata_rejects_injection_in_table_name(self): + with pytest.raises(DrillError, match="invalid identifier"): + make_client().columns("dfs.tmp", "t' OR '1'='1") + + +class TestManagement: + @respx.mock + def test_storage_plugins_are_redacted(self): + respx.get(f"{BASE}/storage.json").mock( + return_value=httpx.Response( + 200, + json=[ + { + "name": "s3", + "config": {"type": "file", "fs.s3a.secret.key": "verysecret"}, + } + ], + ) + ) + plugins = make_client().storage_plugins() + assert plugins[0]["config"]["fs.s3a.secret.key"] == "***REDACTED***" + assert plugins[0]["name"] == "s3" + + @respx.mock + def test_cluster_status_merges_cluster_and_status(self): + respx.get(f"{BASE}/cluster.json").mock( + return_value=httpx.Response(200, json={"drillbits": [{"address": "n1"}]}) + ) + respx.get(f"{BASE}/status.json").mock( + return_value=httpx.Response(200, json={"status": "Running!"}) + ) + result = make_client().cluster_status() + assert result["drillbits"] == [{"address": "n1"}] + assert result["status"] == "Running!" + + @respx.mock + def test_profiles_are_limited(self): + respx.get(f"{BASE}/profiles.json").mock( + return_value=httpx.Response( + 200, + json={ + "finishedQueries": [{"queryId": f"q{i}"} for i in range(10)], + "runningQueries": [], + }, + ) + ) + assert len(make_client().profiles(limit=3)) == 3 + + @respx.mock + def test_profiles_include_running_queries_first(self): + respx.get(f"{BASE}/profiles.json").mock( + return_value=httpx.Response( + 200, + json={ + "runningQueries": [{"queryId": "live"}], + "finishedQueries": [{"queryId": "done"}], + }, + ) + ) + assert make_client().profiles(limit=5)[0]["queryId"] == "live" + + @respx.mock + def test_profile_fetches_one_query(self): + respx.get(f"{BASE}/profiles/abc.json").mock( + return_value=httpx.Response(200, json={"queryId": "abc", "state": "COMPLETED"}) + ) + assert make_client().profile("abc")["state"] == "COMPLETED" + + @respx.mock + def test_profile_rejects_a_malformed_query_id(self): + with pytest.raises(DrillError, match="invalid"): + make_client().profile("../../etc/passwd") + + @respx.mock + def test_cancel_query(self): + route = respx.get(f"{BASE}/profiles/cancel/abc").mock( + return_value=httpx.Response(200, text="Cancelled query abc") + ) + assert "abc" in make_client().cancel_query("abc") + assert route.called + + @respx.mock + def test_cancel_rejects_a_malformed_query_id(self): + with pytest.raises(DrillError, match="invalid"): + make_client().cancel_query("abc; rm -rf /") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_client_rest.py -k "Metadata or Management" -v` +Expected: FAIL — `AttributeError: 'RestClient' object has no attribute 'schemas'` + +- [ ] **Step 3: Add the metadata and management methods** + +Add this import near the top of `drill_mcp/client_rest.py`: + +```python +from .redact import redact +``` + +Add a query-id validator beside `quote_identifier`: + +```python +_QUERY_ID = re.compile(r"^[A-Za-z0-9-]+$") + + +def _check_query_id(query_id: str) -> str: + if not _QUERY_ID.match(query_id): + raise DrillError(f"invalid query id: {query_id!r}") + return query_id +``` + +Append these methods to `RestClient`: + +```python + # -- metadata ---------------------------------------------------------- + + def schemas(self) -> list[dict[str, Any]]: + result = self.query( + "SELECT SCHEMA_NAME, TYPE FROM INFORMATION_SCHEMA.SCHEMATA " + "ORDER BY SCHEMA_NAME", + max_rows=10_000, + ) + return [ + {"name": row.get("SCHEMA_NAME"), "type": row.get("TYPE")} + for row in result.rows + ] + + def tables(self, schema: str) -> list[dict[str, Any]]: + result = self.query( + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.`TABLES` " + f"WHERE TABLE_SCHEMA = {quote_literal_path(schema)} ORDER BY TABLE_NAME", + max_rows=10_000, + ) + return [ + {"name": row.get("TABLE_NAME"), "type": row.get("TABLE_TYPE")} + for row in result.rows + ] + + def columns(self, schema: str, table: str) -> list[dict[str, Any]]: + result = self.query( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + f"WHERE TABLE_SCHEMA = {quote_literal_path(schema)} " + f"AND TABLE_NAME = {quote_literal(table)} ORDER BY ORDINAL_POSITION", + max_rows=10_000, + ) + return [ + { + "name": row.get("COLUMN_NAME"), + "data_type": row.get("DATA_TYPE"), + "nullable": str(row.get("IS_NULLABLE", "")).upper() == "YES", + } + for row in result.rows + ] + + # -- management -------------------------------------------------------- + + def storage_plugins(self) -> list[dict[str, Any]]: + payload = self._request("GET", "/storage.json").json() + return redact(payload) + + def cluster_status(self) -> dict[str, Any]: + cluster = self._request("GET", "/cluster.json").json() + status = self._request("GET", "/status.json").json() + merged = dict(cluster) if isinstance(cluster, dict) else {"cluster": cluster} + if isinstance(status, dict): + merged.update(status) + else: + merged["status"] = status + return merged + + def profiles(self, limit: int) -> list[dict[str, Any]]: + payload = self._request("GET", "/profiles.json").json() + running = payload.get("runningQueries") or [] + finished = payload.get("finishedQueries") or [] + return (list(running) + list(finished))[:limit] + + def profile(self, query_id: str) -> dict[str, Any]: + _check_query_id(query_id) + return self._request("GET", f"/profiles/{query_id}.json").json() + + def cancel_query(self, query_id: str) -> str: + _check_query_id(query_id) + return self._request("GET", f"/profiles/cancel/{query_id}").text +``` + +`quote_literal` and `quote_literal_path` already exist from Task 5; this task +only adds the query-id validator and the methods above. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_client_rest.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/client_rest.py tests/test_client_rest.py +git commit -m "feat: metadata and management endpoints on the REST client" +``` + +--- + +### Task 7: JDBC client + +**Files:** +- Create: `drill_mcp/client_jdbc.py` +- Test: `tests/test_client_jdbc.py` + +**Interfaces:** +- Consumes: `config.Config`, `client_rest.QueryResult`, `client_rest.DrillError`, `client_rest.quote_literal`, `client_rest.quote_literal_path` +- Produces: `drill_mcp.client_jdbc.JdbcClient(config: Config)` with `query`, `schemas`, `tables`, `columns`, `close` — the same signatures as `RestClient`. Management methods are **not** implemented. + +**Note for the implementer:** this task never imports a real JVM. `jaydebeapi` is mocked in tests via `monkeypatch.setitem(sys.modules, ...)`, so the suite runs without the `jdbc` extra installed. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_client_jdbc.py`: + +```python +import sys +from unittest.mock import MagicMock + +import pytest + +from drill_mcp.client_jdbc import JdbcClient +from drill_mcp.client_rest import DrillError +from drill_mcp.config import load_config + + [email protected] +def fake_jaydebeapi(monkeypatch): + module = MagicMock() + cursor = MagicMock() + cursor.description = [("a", None), ("b", None)] + cursor.fetchmany.return_value = [(1, "x")] + connection = MagicMock() + connection.cursor.return_value = cursor + module.connect.return_value = connection + monkeypatch.setitem(sys.modules, "jaydebeapi", module) + return module + + +def make_client(**overrides): + overrides.setdefault("backend", "jdbc") + overrides.setdefault("jdbc_driver_path", "/opt/drill-jdbc-all.jar") + return JdbcClient(load_config(overrides=overrides)) + + +def test_clear_error_when_extra_is_not_installed(monkeypatch): + monkeypatch.setitem(sys.modules, "jaydebeapi", None) + with pytest.raises(DrillError, match=r"drill-mcp\[jdbc\]"): + make_client().query("SELECT 1", max_rows=1) + + +def test_connect_uses_the_configured_driver_and_url(fake_jaydebeapi): + make_client(url="http://drill:8047").query("SELECT 1", max_rows=1) + args = fake_jaydebeapi.connect.call_args + assert args.args[0] == "org.apache.drill.jdbc.Driver" + assert args.args[1].startswith("jdbc:drill:") + assert args.kwargs["jars"] == ["/opt/drill-jdbc-all.jar"] + + +def test_query_returns_columns_and_rows(fake_jaydebeapi): + result = make_client().query("SELECT 1", max_rows=10) + assert result.columns == ["a", "b"] + assert result.rows == [{"a": 1, "b": "x"}] + assert result.truncated is False + + +def test_query_respects_max_rows(fake_jaydebeapi): + cursor = fake_jaydebeapi.connect.return_value.cursor.return_value + cursor.fetchmany.return_value = [(1, "x"), (2, "y")] + result = make_client().query("SELECT 1", max_rows=2) + cursor.fetchmany.assert_called_with(2) + assert result.truncated is True + + +def test_connection_is_reused(fake_jaydebeapi): + client = make_client() + client.query("SELECT 1", max_rows=1) + client.query("SELECT 2", max_rows=1) + assert fake_jaydebeapi.connect.call_count == 1 + + +def test_basic_auth_credentials_are_passed(fake_jaydebeapi): + make_client(auth="basic", user="alice", password="s3cret").query("SELECT 1", max_rows=1) + assert fake_jaydebeapi.connect.call_args.args[2] == ["alice", "s3cret"] + + +def test_kerberos_sets_the_auth_property(fake_jaydebeapi): + make_client(auth="kerberos").query("SELECT 1", max_rows=1) + assert "auth=kerberos" in fake_jaydebeapi.connect.call_args.args[1] + + +def test_schemas_uses_information_schema(fake_jaydebeapi): + cursor = fake_jaydebeapi.connect.return_value.cursor.return_value + cursor.description = [("SCHEMA_NAME", None), ("TYPE", None)] + cursor.fetchmany.return_value = [("dfs.tmp", "file")] + assert make_client().schemas() == [{"name": "dfs.tmp", "type": "file"}] + + +def test_tables_rejects_injection(fake_jaydebeapi): + with pytest.raises(DrillError, match="invalid identifier"): + make_client().tables("dfs'; DROP TABLE x --") + + +def test_driver_error_is_wrapped(fake_jaydebeapi): + fake_jaydebeapi.connect.side_effect = RuntimeError("no route to host") + with pytest.raises(DrillError, match="no route to host"): + make_client().query("SELECT 1", max_rows=1) + + +def test_close_closes_the_connection(fake_jaydebeapi): + client = make_client() + client.query("SELECT 1", max_rows=1) + client.close() + fake_jaydebeapi.connect.return_value.close.assert_called_once() + + +def test_close_is_safe_before_connecting(): + make_client().close() # does not raise +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_client_jdbc.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'drill_mcp.client_jdbc'` + +- [ ] **Step 3: Implement `drill_mcp/client_jdbc.py`** + +```python +"""Drill JDBC backend. + +Optional, installed via `pip install drill-mcp[jdbc]`. It exists mainly because +Kerberos is materially less painful through the Drill JDBC driver than through +Python SPNEGO. Query and metadata only — management endpoints are REST-only. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from .client_rest import DrillError, QueryResult, quote_literal, quote_literal_path +from .config import Config + +DRIVER_CLASS = "org.apache.drill.jdbc.Driver" + + +class JdbcClient: + def __init__(self, config: Config) -> None: + self._config = config + self._connection: Any = None + + # -- connection -------------------------------------------------------- + + def _jdbc_url(self) -> str: + host = urlparse(self._config.url) + netloc = host.netloc or host.path + url = f"jdbc:drill:drillbit={netloc}" + if self._config.auth == "kerberos": + url += ";auth=kerberos" + return url + + def _connect(self) -> Any: + if self._connection is not None: + return self._connection + try: + import jaydebeapi + except ImportError as exc: + raise DrillError( + "the JDBC backend requires the jdbc extra: pip install drill-mcp[jdbc]" + ) from exc + if jaydebeapi is None: + raise DrillError( + "the JDBC backend requires the jdbc extra: pip install drill-mcp[jdbc]" + ) + credentials = ( + [self._config.user, self._config.password] + if self._config.auth == "basic" + else [] + ) + try: + self._connection = jaydebeapi.connect( + DRIVER_CLASS, + self._jdbc_url(), + credentials, + jars=[self._config.jdbc_driver_path], + ) + except Exception as exc: + raise DrillError(f"could not connect to Drill over JDBC: {exc}") from exc + return self._connection + + def close(self) -> None: + if self._connection is not None: + self._connection.close() + self._connection = None + + # -- queries ----------------------------------------------------------- + + def query(self, sql: str, max_rows: int) -> QueryResult: + connection = self._connect() + try: + cursor = connection.cursor() + cursor.execute(sql) + rows = cursor.fetchmany(max_rows) + columns = [description[0] for description in cursor.description or []] + except DrillError: + raise + except Exception as exc: + raise DrillError(str(exc)) from exc + return QueryResult( + columns=columns, + rows=[dict(zip(columns, row)) for row in rows], + truncated=len(rows) >= max_rows, + ) + + # -- metadata ---------------------------------------------------------- + + def schemas(self) -> list[dict[str, Any]]: + result = self.query( + "SELECT SCHEMA_NAME, TYPE FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME", + max_rows=10_000, + ) + return [ + {"name": row.get("SCHEMA_NAME"), "type": row.get("TYPE")} + for row in result.rows + ] + + def tables(self, schema: str) -> list[dict[str, Any]]: + result = self.query( + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.`TABLES` " + f"WHERE TABLE_SCHEMA = {quote_literal_path(schema)} ORDER BY TABLE_NAME", + max_rows=10_000, + ) + return [ + {"name": row.get("TABLE_NAME"), "type": row.get("TABLE_TYPE")} + for row in result.rows + ] + + def columns(self, schema: str, table: str) -> list[dict[str, Any]]: + result = self.query( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + f"WHERE TABLE_SCHEMA = {quote_literal_path(schema)} " + f"AND TABLE_NAME = {quote_literal(table)} ORDER BY ORDINAL_POSITION", + max_rows=10_000, + ) + return [ + { + "name": row.get("COLUMN_NAME"), + "data_type": row.get("DATA_TYPE"), + "nullable": str(row.get("IS_NULLABLE", "")).upper() == "YES", + } + for row in result.rows + ] +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_client_jdbc.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/client_jdbc.py tests/test_client_jdbc.py +git commit -m "feat: optional JDBC backend" +``` + +--- + +### Task 8: Tool layer — query and metadata tools + +**Files:** +- Create: `drill_mcp/server.py` +- Test: `tests/test_server.py` + +**Interfaces:** +- Consumes: `config.Config`, `guard.Policy`/`check`/`PolicyError`, `client_rest.RestClient`/`DrillError`, `client_jdbc.JdbcClient` +- Produces: + - `drill_mcp.server.DrillTools(config: Config, client)` with methods `run_query(sql: str, max_rows: int | None = None) -> dict`, `list_schemas() -> list[dict]`, `list_tables(schema: str) -> list[dict]`, `describe_table(schema: str, table: str) -> list[dict]` + - `drill_mcp.server.ToolError(Exception)` — the single error type surfaced to MCP clients + +**Design note:** tool bodies live on a plain class so they are unit-testable without an MCP session. `build_server` in Task 9 registers the bound methods with FastMCP. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_server.py`: + +```python +from unittest.mock import MagicMock + +import pytest + +from drill_mcp.client_rest import DrillError, QueryResult +from drill_mcp.config import load_config +from drill_mcp.server import DrillTools, ToolError + + +def make_tools(client=None, **overrides): + client = client or MagicMock() + return DrillTools(load_config(overrides=overrides), client) + + +class TestRunQuery: + def test_returns_columns_rows_and_query_id(self): + client = MagicMock() + client.query.return_value = QueryResult(["a"], [{"a": 1}], "q1", False) + result = make_tools(client).run_query("SELECT 1") + assert result["columns"] == ["a"] + assert result["rows"] == [{"a": 1}] + assert result["query_id"] == "q1" + assert result["truncated"] is False + + def test_applies_the_configured_row_cap(self): + client = MagicMock() + client.query.return_value = QueryResult() + make_tools(client, max_rows=100).run_query("SELECT 1") + assert client.query.call_args.kwargs["max_rows"] == 100 + + def test_caller_may_lower_the_cap(self): + client = MagicMock() + client.query.return_value = QueryResult() + make_tools(client, max_rows=100).run_query("SELECT 1", max_rows=10) + assert client.query.call_args.kwargs["max_rows"] == 10 + + def test_caller_may_not_raise_the_cap(self): + client = MagicMock() + client.query.return_value = QueryResult() + make_tools(client, max_rows=100).run_query("SELECT 1", max_rows=10_000) + assert client.query.call_args.kwargs["max_rows"] == 100 + + def test_truncation_is_reported(self): + client = MagicMock() + client.query.return_value = QueryResult(["a"], [{"a": 1}], None, True) + result = make_tools(client, max_rows=1).run_query("SELECT 1") + assert result["truncated"] is True + assert "truncated" in result["note"].lower() + + def test_write_is_rejected_before_reaching_the_client(self): + client = MagicMock() + with pytest.raises(ToolError, match="writable_plugins"): + make_tools(client).run_query("CREATE TABLE dfs.tmp.x AS SELECT 1") + client.query.assert_not_called() + + def test_write_is_allowed_when_the_plugin_is_writable(self): + client = MagicMock() + client.query.return_value = QueryResult() + make_tools(client, writable_plugins=["dfs.tmp"]).run_query( + "CREATE TABLE dfs.tmp.x AS SELECT 1" + ) + client.query.assert_called_once() + + def test_hidden_schema_is_rejected_before_reaching_the_client(self): + client = MagicMock() + with pytest.raises(ToolError, match="hidden"): + make_tools(client, hidden_schemas=["sys"]).run_query("SELECT * FROM sys.options") + client.query.assert_not_called() + + def test_drill_errors_are_surfaced_as_tool_errors(self): + client = MagicMock() + client.query.side_effect = DrillError("VALIDATION ERROR: no such table") + with pytest.raises(ToolError, match="no such table"): + make_tools(client).run_query("SELECT * FROM nope") + + +class TestListSchemas: + def test_returns_all_schemas_by_default(self): + client = MagicMock() + client.schemas.return_value = [{"name": "dfs.tmp"}, {"name": "sys"}] + assert len(make_tools(client).list_schemas()) == 2 + + def test_filters_hidden_schemas(self): + client = MagicMock() + client.schemas.return_value = [ + {"name": "dfs.tmp"}, + {"name": "sys"}, + {"name": "INFORMATION_SCHEMA"}, + ] + result = make_tools(client, hidden_schemas=["sys", "INFORMATION_SCHEMA"]).list_schemas() + assert [s["name"] for s in result] == ["dfs.tmp"] + + def test_filtering_is_case_insensitive(self): + client = MagicMock() + client.schemas.return_value = [{"name": "SYS"}, {"name": "dfs.tmp"}] + result = make_tools(client, hidden_schemas=["sys"]).list_schemas() + assert [s["name"] for s in result] == ["dfs.tmp"] + + def test_filters_child_schemas_of_a_hidden_parent(self): + client = MagicMock() + client.schemas.return_value = [{"name": "sys.mem"}, {"name": "dfs.tmp"}] + result = make_tools(client, hidden_schemas=["sys"]).list_schemas() + assert [s["name"] for s in result] == ["dfs.tmp"] + + +class TestListTables: + def test_lists_tables(self): + client = MagicMock() + client.tables.return_value = [{"name": "t", "type": "TABLE"}] + assert make_tools(client).list_tables("dfs.tmp") == [{"name": "t", "type": "TABLE"}] + + def test_hidden_schema_is_refused(self): + client = MagicMock() + with pytest.raises(ToolError, match="hidden"): + make_tools(client, hidden_schemas=["sys"]).list_tables("sys") + client.tables.assert_not_called() + + def test_information_schema_still_works_internally_when_hidden(self): + """Hiding INFORMATION_SCHEMA must not break metadata tools.""" + client = MagicMock() + client.tables.return_value = [{"name": "t", "type": "TABLE"}] + tools = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]) + assert tools.list_tables("dfs.tmp") == [{"name": "t", "type": "TABLE"}] + + +class TestDescribeTable: + def test_describes_columns(self): + client = MagicMock() + client.columns.return_value = [{"name": "id", "data_type": "INTEGER", "nullable": True}] + assert make_tools(client).describe_table("dfs.tmp", "t")[0]["name"] == "id" + + def test_hidden_schema_is_refused(self): + client = MagicMock() + with pytest.raises(ToolError, match="hidden"): + make_tools(client, hidden_schemas=["sys"]).describe_table("sys", "options") + client.columns.assert_not_called() + + def test_unknown_table_error_is_surfaced(self): + client = MagicMock() + client.columns.side_effect = DrillError("invalid identifier") + with pytest.raises(ToolError, match="invalid identifier"): + make_tools(client).describe_table("dfs.tmp", "nope") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_server.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'drill_mcp.server'` + +- [ ] **Step 3: Implement the tool layer in `drill_mcp/server.py`** + +```python +"""MCP tool layer. + +Tool bodies live on `DrillTools` as plain methods so they can be unit-tested +without standing up an MCP session; `build_server` registers the bound methods +with FastMCP. +""" + +from __future__ import annotations + +from typing import Any + +from .client_rest import DrillError +from .config import Config +from .guard import Policy, PolicyError, check, matches_prefix + + +class ToolError(Exception): + """The single error type surfaced to MCP clients. Never carries a traceback.""" + + +class DrillTools: + def __init__(self, config: Config, client: Any) -> None: + self._config = config + self._client = client + self._policy = Policy.from_config(config) + + # -- helpers ----------------------------------------------------------- + + def _effective_max_rows(self, requested: int | None) -> int: + cap = self._config.max_rows + if requested is None or requested <= 0: + return cap + return min(requested, cap) + + def _refuse_if_hidden(self, schema: str) -> None: + if matches_prefix(schema, self._policy.hidden_schemas): + raise ToolError(f"schema '{schema}' is hidden by configuration") + + def _visible(self, schema: str | None) -> bool: + return not (schema and matches_prefix(schema, self._policy.hidden_schemas)) + + # -- tools ------------------------------------------------------------- + + def run_query(self, sql: str, max_rows: int | None = None) -> dict[str, Any]: + """Run a single SQL statement against Drill and return its rows.""" + try: + check(sql, self._policy) + except PolicyError as exc: + raise ToolError(str(exc)) from exc + + limit = self._effective_max_rows(max_rows) + try: + result = self._client.query(sql, max_rows=limit) + except DrillError as exc: + raise ToolError(str(exc)) from exc + + payload: dict[str, Any] = { + "columns": result.columns, + "rows": result.rows, + "query_id": result.query_id, + "truncated": result.truncated, + } + if result.truncated: + payload["note"] = ( + f"Results were truncated at {limit} rows. " + "Narrow the query or aggregate to see the rest." + ) + return payload + + def list_schemas(self) -> list[dict[str, Any]]: + """List every schema visible on the cluster.""" + try: + schemas = self._client.schemas() + except DrillError as exc: + raise ToolError(str(exc)) from exc + return [s for s in schemas if self._visible(s.get("name"))] + + def list_tables(self, schema: str) -> list[dict[str, Any]]: + """List the tables in one schema.""" + self._refuse_if_hidden(schema) + try: + return self._client.tables(schema) + except DrillError as exc: + raise ToolError(str(exc)) from exc + + def describe_table(self, schema: str, table: str) -> list[dict[str, Any]]: + """List a table's columns with their types and nullability.""" + self._refuse_if_hidden(schema) + try: + return self._client.columns(schema, table) + except DrillError as exc: + raise ToolError(str(exc)) from exc +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_server.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/server.py tests/test_server.py +git commit -m "feat: query and metadata MCP tools" +``` + +--- + +### Task 9: Tool layer — management tools and `SHOW` filtering + +**Files:** +- Modify: `drill_mcp/server.py` +- Test: `tests/test_server.py` (append) + +**Interfaces:** +- Consumes: everything from Task 8 +- Produces, all on `DrillTools`: `list_storage_plugins() -> list[dict]`, `cluster_status() -> dict`, `list_profiles(limit: int = 20) -> list[dict]`, `get_profile(query_id: str) -> dict`, `cancel_query(query_id: str) -> str` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_server.py`: + +```python +class TestManagementTools: + def test_list_storage_plugins_passes_through_redacted_output(self): + client = MagicMock() + client.storage_plugins.return_value = [ + {"name": "s3", "config": {"secret": "***REDACTED***"}} + ] + assert make_tools(client).list_storage_plugins()[0]["name"] == "s3" + + def test_list_storage_plugins_hides_plugins_backing_hidden_schemas(self): + client = MagicMock() + client.storage_plugins.return_value = [{"name": "sys"}, {"name": "dfs"}] + result = make_tools(client, hidden_schemas=["sys"]).list_storage_plugins() + assert [p["name"] for p in result] == ["dfs"] + + def test_cluster_status(self): + client = MagicMock() + client.cluster_status.return_value = {"status": "Running!"} + assert make_tools(client).cluster_status()["status"] == "Running!" + + def test_list_profiles_uses_the_default_limit(self): + client = MagicMock() + client.profiles.return_value = [] + make_tools(client).list_profiles() + assert client.profiles.call_args.kwargs["limit"] == 20 + + def test_list_profiles_honours_an_explicit_limit(self): + client = MagicMock() + client.profiles.return_value = [] + make_tools(client).list_profiles(limit=5) + assert client.profiles.call_args.kwargs["limit"] == 5 + + def test_get_profile(self): + client = MagicMock() + client.profile.return_value = {"queryId": "abc"} + assert make_tools(client).get_profile("abc")["queryId"] == "abc" + + def test_cancel_query(self): + client = MagicMock() + client.cancel_query.return_value = "Cancelled" + assert make_tools(client).cancel_query("abc") == "Cancelled" + + def test_management_tools_are_unavailable_on_a_client_without_them(self): + client = MagicMock(spec=["query", "schemas", "tables", "columns"]) + with pytest.raises(ToolError, match="REST"): + make_tools(client).cluster_status() + + def test_drill_errors_become_tool_errors(self): + client = MagicMock() + client.profile.side_effect = DrillError("no such query") + with pytest.raises(ToolError, match="no such query"): + make_tools(client).get_profile("abc") + + +class TestShowFiltering: + """SHOW is evaluated server-side by Drill, so rows are filtered on return.""" + + def test_show_schemas_rows_are_filtered(self): + client = MagicMock() + client.query.return_value = QueryResult( + ["SCHEMA_NAME"], + [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], + ) + result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW SCHEMAS") + assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] + + def test_show_databases_rows_are_filtered(self): + client = MagicMock() + client.query.return_value = QueryResult( + ["SCHEMA_NAME"], + [{"SCHEMA_NAME": "INFORMATION_SCHEMA"}, {"SCHEMA_NAME": "dfs"}], + ) + result = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]).run_query( + "SHOW DATABASES" + ) + assert result["rows"] == [{"SCHEMA_NAME": "dfs"}] + + def test_ordinary_select_rows_are_not_filtered(self): + client = MagicMock() + client.query.return_value = QueryResult( + ["SCHEMA_NAME"], [{"SCHEMA_NAME": "sys"}] + ) + result = make_tools(client, hidden_schemas=["sys"]).run_query( + "SELECT SCHEMA_NAME FROM dfs.tmp.notes" + ) + assert result["rows"] == [{"SCHEMA_NAME": "sys"}] + + def test_show_filtering_is_a_no_op_without_hidden_schemas(self): + client = MagicMock() + client.query.return_value = QueryResult(["SCHEMA_NAME"], [{"SCHEMA_NAME": "sys"}]) + result = make_tools(client).run_query("SHOW SCHEMAS") + assert result["rows"] == [{"SCHEMA_NAME": "sys"}] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_server.py -k "Management or Show" -v` +Expected: FAIL — `AttributeError: 'DrillTools' object has no attribute 'list_storage_plugins'` + +- [ ] **Step 3: Add management tools and `SHOW` filtering** + +Add this module-level constant near the top of `drill_mcp/server.py`: + +```python +import re + +# `SHOW SCHEMAS` / `SHOW DATABASES` are evaluated server-side by Drill, so the +# guard cannot filter them — their rows are filtered on the way back instead. +_SHOW_SCHEMAS = re.compile(r"^\s*SHOW\s+(SCHEMAS|DATABASES)\b", re.IGNORECASE) +``` + +In `run_query`, filter the rows before building the payload — insert this +immediately after the `self._client.query(...)` call: + +```python + rows = result.rows + if self._policy.hidden_schemas and _SHOW_SCHEMAS.match(sql): + rows = [row for row in rows if self._visible(_first_value(row))] +``` + +and use `rows` in place of `result.rows` in the payload. + +Add the helper at module level: + +```python +def _first_value(row: dict[str, Any]) -> str | None: + for value in row.values(): + return str(value) if value is not None else None + return None +``` + +Append the management methods to `DrillTools`: + +```python + def _require_management(self, name: str) -> Any: + method = getattr(self._client, name, None) + if method is None: + raise ToolError( + f"'{name}' needs a REST connection to Drill; the JDBC backend " + "does not expose management endpoints" + ) + return method + + def list_storage_plugins(self) -> list[dict[str, Any]]: + """List storage plugin configurations, with all secrets redacted.""" + try: + plugins = self._require_management("storage_plugins")() + except DrillError as exc: + raise ToolError(str(exc)) from exc + return [p for p in plugins if self._visible(p.get("name"))] + + def cluster_status(self) -> dict[str, Any]: + """Report Drillbit membership and overall cluster status.""" + try: + return self._require_management("cluster_status")() + except DrillError as exc: + raise ToolError(str(exc)) from exc + + def list_profiles(self, limit: int = 20) -> list[dict[str, Any]]: + """List recent and running query profiles, newest first.""" + try: + return self._require_management("profiles")(limit=limit) + except DrillError as exc: + raise ToolError(str(exc)) from exc + + def get_profile(self, query_id: str) -> dict[str, Any]: + """Fetch the full profile for one query id.""" + try: + return self._require_management("profile")(query_id) + except DrillError as exc: + raise ToolError(str(exc)) from exc + + def cancel_query(self, query_id: str) -> str: + """Cancel a running query by its query id.""" + try: + return self._require_management("cancel_query")(query_id) + except DrillError as exc: + raise ToolError(str(exc)) from exc +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_server.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add drill_mcp/server.py tests/test_server.py +git commit -m "feat: management tools and SHOW SCHEMAS filtering" +``` + +--- + +### Task 10: Server wiring, entry point, and documentation + +**Files:** +- Modify: `drill_mcp/server.py` +- Create: `README.md`, `drill.example.yaml` +- Test: `tests/test_server.py` (append) + +**Interfaces:** +- Consumes: everything above +- Produces: + - `drill_mcp.server.build_client(config: Config) -> RestClient | JdbcClient` + - `drill_mcp.server.build_server(config: Config) -> FastMCP` + - `drill_mcp.server.main(argv: list[str] | None = None) -> int` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_server.py`: + +```python +from drill_mcp.client_jdbc import JdbcClient +from drill_mcp.client_rest import RestClient +from drill_mcp.server import build_client, build_server + + +class TestWiring: + def test_rest_backend_builds_a_rest_client(self): + assert isinstance(build_client(load_config()), RestClient) + + def test_jdbc_backend_builds_a_jdbc_client(self): + cfg = load_config(overrides={"backend": "jdbc", "jdbc_driver_path": "/x.jar"}) + assert isinstance(build_client(cfg), JdbcClient) + + def test_all_nine_tools_are_registered(self): + server = build_server(load_config()) + names = {tool.name for tool in server._tool_manager.list_tools()} + assert names == { + "run_query", + "list_schemas", + "list_tables", + "describe_table", + "list_storage_plugins", + "cluster_status", + "list_profiles", + "get_profile", + "cancel_query", + } + + def test_every_tool_has_a_description(self): + server = build_server(load_config()) + assert all(tool.description for tool in server._tool_manager.list_tools()) + + def test_no_write_or_mutation_tools_are_registered(self): + server = build_server(load_config()) + names = {tool.name for tool in server._tool_manager.list_tools()} + forbidden = {"create_storage_plugin", "update_storage_plugin", + "delete_storage_plugin", "set_option", "alter_system"} + assert not (names & forbidden) + + +class TestMain: + def test_config_error_exits_nonzero_with_a_message(self, capsys): + from drill_mcp.server import main + + assert main(["--config", "/nonexistent.yaml"]) == 1 + assert "not found" in capsys.readouterr().err + + def test_cli_flags_reach_the_config(self, monkeypatch): + from drill_mcp import server as server_module + + captured = {} + monkeypatch.setattr( + server_module, "build_server", lambda cfg: captured.setdefault("cfg", cfg) or MagicMock() + ) + server_module.main(["--url", "http://cli:8047", "--max-rows", "7"]) + assert captured["cfg"].url == "http://cli:8047" + assert captured["cfg"].max_rows == 7 +``` + +**Note:** `server._tool_manager.list_tools()` reaches into FastMCP internals. If your `mcp` version exposes a public accessor, use it and adjust the test. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_server.py -k "Wiring or Main" -v` +Expected: FAIL — `ImportError: cannot import name 'build_client' from 'drill_mcp.server'` + +- [ ] **Step 3: Add wiring and the entry point** + +Add to the imports at the top of `drill_mcp/server.py`: + +```python +import argparse +import sys + +from mcp.server.fastmcp import FastMCP + +from .client_rest import RestClient +from .config import ConfigError, load_config +``` + +Append to `drill_mcp/server.py`: + +```python +def build_client(config: Config) -> Any: + if config.backend == "jdbc": + from .client_jdbc import JdbcClient + + return JdbcClient(config) + return RestClient(config) + + +def build_server(config: Config) -> FastMCP: + client = build_client(config) + tools = DrillTools(config, client) + server = FastMCP("drill") + for method in ( + tools.run_query, + tools.list_schemas, + tools.list_tables, + tools.describe_table, + tools.list_storage_plugins, + tools.cluster_status, + tools.list_profiles, + tools.get_profile, + tools.cancel_query, + ): + server.add_tool(method, name=method.__name__, description=method.__doc__) + return server + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="drill-mcp", description="MCP server for Apache Drill") + parser.add_argument("--config", help="path to a YAML config file") + parser.add_argument("--url", help="Drill HTTP endpoint, e.g. http://localhost:8047") + parser.add_argument("--backend", choices=["rest", "jdbc"]) + parser.add_argument("--auth", choices=["none", "basic", "kerberos"]) + parser.add_argument("--max-rows", type=int, dest="max_rows") + parser.add_argument( + "--writable-plugin", + action="append", + dest="writable_plugins", + metavar="PLUGIN", + help="permit data writes into this plugin; repeatable, empty by default", + ) + parser.add_argument( + "--hidden-schema", + action="append", + dest="hidden_schemas", + metavar="SCHEMA", + help="hide this schema from listings and queries; repeatable", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + overrides = { + key: value + for key, value in vars(args).items() + if key != "config" and value is not None + } + try: + config = load_config(args.config, overrides=overrides) + except ConfigError as exc: + print(f"drill-mcp: configuration error: {exc}", file=sys.stderr) + return 1 + build_server(config).run() + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_server.py -v` +Expected: PASS + +- [ ] **Step 5: Write `drill.example.yaml`** + +```yaml +# Apache Drill MCP server configuration. +# Every value below is a default; delete what you do not need to change. + +url: http://localhost:8047 +backend: rest # rest | jdbc +auth: none # none | basic | kerberos + +# Credentials may also come from DRILL_USER / DRILL_PASSWORD. +# user: alice +# password: s3cret + +max_rows: 1000 +timeout_seconds: 60 + +# Plugins permitted to accept CTAS / CREATE VIEW / DROP. Empty means no writes. +writable_plugins: [] +# writable_plugins: [dfs.tmp] + +# Schemas hidden from listings and rejected in queries. +hidden_schemas: [] +# hidden_schemas: [sys, INFORMATION_SCHEMA] + +# Required when backend is jdbc. +# jdbc_driver_path: /opt/drill/jars/jdbc-driver/drill-jdbc-all.jar +``` + +- [ ] **Step 6: Write `README.md`** + +````markdown +# drill-mcp + +An MCP server for [Apache Drill](https://drill.apache.org/). Lets an MCP client +enumerate schemata, run SQL, and inspect storage plugin and cluster state. + +## Install + +```bash +pip install drill-mcp # REST backend +pip install drill-mcp[jdbc] # adds the JDBC backend (needs a JVM) +pip install drill-mcp[kerberos] # adds SPNEGO for the REST backend +``` + +## Run + +```bash +drill-mcp --url http://localhost:8047 +drill-mcp --config drill.yaml +``` + +Register it with an MCP client: + +```json +{ + "mcpServers": { + "drill": { + "command": "drill-mcp", + "args": ["--config", "/etc/drill-mcp/drill.yaml"] + } + } +} +``` + +## Tools + +| Tool | Description | +|---|---| +| `run_query` | Run one SQL statement | +| `list_schemas` | List visible schemas | +| `list_tables` | List tables in a schema | +| `describe_table` | Column names, types, nullability | +| `list_storage_plugins` | Plugin configs, secrets redacted | +| `cluster_status` | Drillbit membership and status | +| `list_profiles` | Recent and running queries | +| `get_profile` | Full profile for one query id | +| `cancel_query` | Cancel a running query | + +## Safety model + +- **Writes are denied by default.** `CREATE TABLE AS`, `CREATE VIEW`, and `DROP` + are permitted only into plugins listed in `writable_plugins`. Everything else + — `INSERT`, `ALTER`, `USE` — is always rejected. +- **`ALTER SYSTEM` and storage plugin editing are not implemented.** There is no + flag that turns them on. +- **Secrets are always redacted** from storage plugin output. +- **Schemas can be hidden** via `hidden_schemas`, which filters them from + listings and rejects queries that reference them. Hiding `INFORMATION_SCHEMA` + does not break the metadata tools, which query it internally. +- Statements are checked with a real SQL parser, so writes hidden in comments, + string literals, or stacked statements do not slip through. + +See `drill.example.yaml` for the full configuration. + +## Development + +```bash +pip install -e ".[dev]" +pytest # unit tests, no cluster or JVM needed +pytest -m integration # requires a live Drill at DRILL_URL +``` +```` + +- [ ] **Step 7: Run the whole suite with coverage** + +Run: `pytest --cov=drill_mcp --cov-report=term-missing` +Expected: PASS. `guard.py` and `redact.py` at 100%; everything else at 90%+. +Add tests for any uncovered branch before committing. + +- [ ] **Step 8: Verify the server actually starts** + +Run: `drill-mcp --config /nonexistent.yaml` +Expected: exits 1 with `drill-mcp: configuration error: config file not found: /nonexistent.yaml` + +Run: `python -c "from drill_mcp.server import build_server; from drill_mcp.config import load_config; print(len(build_server(load_config())._tool_manager.list_tools()))"` +Expected: prints `9` + +- [ ] **Step 9: Commit** + +```bash +git add drill_mcp/server.py README.md drill.example.yaml tests/test_server.py +git commit -m "feat: server wiring, CLI entry point, and documentation" +``` + +--- + +## Verification Checklist + +Run before declaring the plan complete: + +- [ ] `pytest --cov=drill_mcp --cov-report=term-missing` passes with no failures +- [ ] `guard.py` and `redact.py` at 100% line coverage +- [ ] `grep -ri "alter system\|create_storage\|update_storage\|delete_storage" drill_mcp/` returns nothing outside comments and the README +- [ ] The suite passes with neither `jaydebeapi` nor `JPype1` installed +- [ ] `build_server(load_config())` registers exactly the nine tools listed in Task 10
