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"
