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 ee18ced2453e2e6f8b4db2c447d7b756eb9abe6b
Author: cgivre <[email protected]>
AuthorDate: Tue Aug 11 16:43:31 2026 -0400

    feat: Drill REST client with auth and query execution
    
    Fixes two defects found in the task brief's reference implementation:
    - _login's URL-based check ("j_security_check" in str(response.url))
      false-positives on a successful, unredirected login, since the
      response URL still points at the login endpoint. Status code alone
      is the only reliable signal here.
    - test_connection_failure_message_names_the_url_not_the_password only
      mocked /query.json; since auth=basic logs in first, the unmocked
      /j_security_check route raised respx's AllMockedAssertionError
      instead of exercising the intended DrillError path. Now mocks both
      endpoints, matching how a real outage would behave.
---
 drill_mcp/client_rest.py  | 202 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_client_rest.py | 174 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 376 insertions(+)

diff --git a/drill_mcp/client_rest.py b/drill_mcp/client_rest.py
new file mode 100644
index 0000000..4781462
--- /dev/null
+++ b/drill_mcp/client_rest.py
@@ -0,0 +1,202 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+# either express or implied.  See the License for the specific
+# language governing permissions and limitations under the
+# License.
+#
+
+"""Drill REST backend.
+
+Talks to a Drillbit's HTTP endpoints. Metadata methods (Task 6) 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
+
+# -- quoting -----------------------------------------------------------------
+#
+# Trust boundary: schema and table names arrive from the model and are
+# interpolated into query strings (Drill's REST API has no bind parameters).
+# `_IDENTIFIER` must reject anything that could break out of the surrounding
+# single quotes or change SQL structure: no quotes, backslashes, semicolons,
+# whitespace, or empty segments. `+` requires at least one character, so the
+# empty string never matches, and `quote_literal_path` splits on "." and
+# validates every segment, so a lone "." (which splits into two empty
+# segments) is rejected too.
+_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}'"
+
+
+# -- client --------------------------------------------------------------
+
+
+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
+        # Drill's j_security_check endpoint (standard Java EE FORM auth) 
returns
+        # 200 for both outcomes when no redirect target is configured; failure
+        # is not reliably distinguishable by URL shape (an unredirected success
+        # response's URL also still points at "/j_security_check", so that
+        # check is vacuous at best and a false positive at worst). Status code
+        # is the only signal we can trust here.
+        if response.status_code >= 400:
+            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}"
diff --git a/tests/test_client_rest.py b/tests/test_client_rest.py
new file mode 100644
index 0000000..affb997
--- /dev/null
+++ b/tests/test_client_rest.py
@@ -0,0 +1,174 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+# either express or implied.  See the License for the specific
+# language governing permissions and limitations under the
+# License.
+#
+
+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):
+        # A real connection failure affects every request to the host, 
including
+        # the basic-auth login that precedes the first query, so both endpoints
+        # must fail the same way for this to simulate a real outage.
+        
respx.post(f"{BASE}/j_security_check").mock(side_effect=httpx.ConnectError("refused"))
+        
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

Reply via email to