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 9ac1e364c677fd94f317786fd416f7f74b286923
Author: cgivre <[email protected]>
AuthorDate: Tue Aug 11 15:02:15 2026 -0400

    feat: package skeleton and configuration loading
---
 .gitignore            |  7 ++++
 README.md             |  3 ++
 drill_mcp/__init__.py |  1 +
 drill_mcp/config.py   | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++
 pyproject.toml        | 31 +++++++++++++++++
 tests/test_config.py  | 83 ++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 221 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b733d78
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+__pycache__/
+*.egg-info/
+.pytest_cache/
+.coverage
+dist/
+build/
+.venv/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7c493cb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# drill-mcp
+
+MCP server for Apache Drill.
diff --git a/drill_mcp/__init__.py b/drill_mcp/__init__.py
new file mode 100644
index 0000000..3dc1f76
--- /dev/null
+++ b/drill_mcp/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.1.0"
diff --git a/drill_mcp/config.py b/drill_mcp/config.py
new file mode 100644
index 0000000..b993edb
--- /dev/null
+++ b/drill_mcp/config.py
@@ -0,0 +1,96 @@
+"""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
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a4e096f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,31 @@
+[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'"
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..34c66fb
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,83 @@
+import pytest
+from pydantic import ValidationError
+
+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(ValidationError, match="frozen"):
+        cfg.url = "http://elsewhere:8047";

Reply via email to