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 81c02ae3f7c031a928ee166f030b2a50e2fdf534
Author: cgivre <[email protected]>
AuthorDate: Wed Aug 12 16:24:19 2026 -0400

    Various fixes
---
 README.md                 |  4 ++--
 docs/tools.md             | 18 +++++++++++++----
 drill_mcp/client_rest.py  | 48 +++++++++++++++++++++++++++++++++-----------
 drill_mcp/config.py       | 11 +++++++++-
 drill_mcp/redact.py       | 28 ++++++++++++++++++++++++++
 drill_mcp/server.py       | 37 ++++++++++++++++++++++++++++++----
 pyproject.toml            |  2 --
 tests/test_client_jdbc.py |  2 +-
 tests/test_client_rest.py | 41 ++++++++++++++++++++++++++++++++++++-
 tests/test_config.py      | 11 ++++++++++
 tests/test_redact.py      | 21 +++++++++++++++++++
 tests/test_server.py      | 51 ++++++++++++++++++++++++++++++++++++++++-------
 12 files changed, 240 insertions(+), 34 deletions(-)

diff --git a/README.md b/README.md
index 24f4615..baf20cf 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# drill-mcp
+# Drill-MCP Server
 
-An [MCP](https://modelcontextprotocol.io/) server for [Apache 
Drill](https://drill.apache.org/).
+The official [MCP](https://modelcontextprotocol.io/) server for [Apache 
Drill](https://drill.apache.org/).
 It lets an MCP client run read-only (and narrowly, explicitly allow-listed
 write) SQL against a Drill cluster, and inspect schemas, storage plugins,
 and cluster/query state. It does not implement Drill administration
diff --git a/docs/tools.md b/docs/tools.md
index 7897218..1c8ff5e 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -397,7 +397,12 @@ client surfaces defaults, since a caller passing no 
`limit` at all still
 gets at most 20 profiles back, not an unbounded list.
 
 **Returns** a list of profile summary dicts, shape defined by Drill's
-`/profiles.json`:
+`/profiles.json`, passed through the same secret redaction as
+`list_storage_plugins` (profiles are cluster-wide and can carry other
+users' connection strings). Any entry whose query text names a
+`hidden_schemas` entry is dropped entirely, the same protection
+`list_schemas`/`list_tables` apply — otherwise a hidden schema's name would
+leak out as data in another user's query text:
 
 ```json
 [
@@ -431,9 +436,12 @@ Fetches the full profile for one query id.
 |---|---|---|---|
 | `query_id` | string | no | Drill's query UUID, as returned in `run_query`'s 
`query_id` field (REST backend only — always `null` on JDBC). Validated against 
`[A-Za-z0-9-]+`. |
 
-**Returns** Drill's full profile JSON for that query, unmodified — the
-complete `/profiles/<query_id>.json` payload (fragments, operator metrics,
-timing, etc.), which can be large.
+**Returns** Drill's full profile JSON for that query — the complete
+`/profiles/<query_id>.json` payload (fragments, operator metrics, timing,
+etc.), which can be large — passed through the same secret redaction as
+`list_storage_plugins`. A full profile embeds Drill's serialized physical
+plan, which for JDBC and HTTP storage plugins can carry plugin
+configuration, so this is not returned unmodified.
 
 **Errors**
 
@@ -442,6 +450,8 @@ timing, etc.), which can be large.
 - `query_id must be a string` — wrong type.
 - `invalid query id: '<x>'` — `query_id` contains characters outside
   `[A-Za-z0-9-]`. Rejected before any request is made.
+- `profile '<id>' references a hidden schema` — the profile's query text
+  names a `hidden_schemas` entry.
 - Drill's own error text otherwise (e.g. no profile with that id).
 
 ---
diff --git a/drill_mcp/client_rest.py b/drill_mcp/client_rest.py
index 2165ba0..110fa43 100644
--- a/drill_mcp/client_rest.py
+++ b/drill_mcp/client_rest.py
@@ -31,6 +31,7 @@ import re
 from collections.abc import Callable
 from dataclasses import dataclass, field
 from typing import Any
+from urllib.parse import urlparse, urlunparse
 
 import httpx
 
@@ -180,6 +181,22 @@ def _check_query_id(query_id: str) -> str:
     return query_id
 
 
+def _safe_url(url: str) -> str:
+    """Return `url` with any embedded userinfo (e.g. a password) stripped.
+
+    `config.url` is free-form and unvalidated, so nothing stops a value like
+    `http://alice:s3cret@drill:8047`. Every message that echoes the URL back
+    to the model must use this instead of the raw config value -- the
+    `client_jdbc.py` backend already applies this exact defense to its
+    connection string; the REST backend must not diverge.
+    """
+    parsed = urlparse(url)
+    if not parsed.hostname:
+        return url
+    netloc = parsed.hostname if parsed.port is None else 
f"{parsed.hostname}:{parsed.port}"
+    return urlunparse(parsed._replace(netloc=netloc))
+
+
 def _json(response: httpx.Response, url: str) -> Any:
     """Decode a response body as JSON, converting a decode failure to 
`DrillError`.
 
@@ -512,23 +529,23 @@ class RestClient:
         # "/j_security_check", so that check false-positives on success.)
         if response.status_code >= 400:
             raise DrillError(
-                f"authentication endpoint at {self._config.url} returned "
+                f"authentication endpoint at {_safe_url(self._config.url)} 
returned "
                 f"HTTP {response.status_code}"
             )
         if _contains_invalid_credentials_marker(response.text):
             raise DrillError(
-                f"authentication failed for user {self._config.user!r} at 
{self._config.url}"
+                f"authentication failed for user {self._config.user!r} at 
{_safe_url(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"request to {_safe_url(self._config.url)} timed out after "
                 f"{self._config.timeout_seconds}s"
             )
         return DrillError(
-            f"could not reach Drill at {self._config.url} "
+            f"could not reach Drill at {_safe_url(self._config.url)} "
             f"(auth mode: {self._config.auth}): {type(exc).__name__}"
         )
 
@@ -546,7 +563,7 @@ class RestClient:
 
         if response.status_code == 401:
             raise DrillError(
-                f"authentication rejected by Drill at {self._config.url} "
+                f"authentication rejected by Drill at 
{_safe_url(self._config.url)} "
                 f"for user {self._config.user!r}"
             )
         if response.status_code >= 400:
@@ -561,13 +578,20 @@ class RestClient:
             "/query.json",
             json={"queryType": "SQL", "query": sql, "autoLimit": max_rows},
         )
-        payload = _json(response, self._config.url)
+        payload = _json(response, _safe_url(self._config.url))
         rows = payload.get("rows") or []
+        truncated = max_rows > 0 and len(rows) >= max_rows
+        # Defense in depth: `autoLimit` asks Drill to cap rows server-side,
+        # but the cap must not depend entirely on Drill honoring that field.
+        # Slice client-side too, exactly like `JdbcClient.query`'s
+        # `fetchmany(max_rows)` -- the two backends must agree on this.
+        if max_rows > 0:
+            rows = rows[:max_rows]
         return QueryResult(
             columns=payload.get("columns") or [],
             rows=rows,
             query_id=payload.get("queryId"),
-            truncated=max_rows > 0 and len(rows) >= max_rows,
+            truncated=truncated,
             metadata=payload.get("metadata") or [],
         )
 
@@ -594,11 +618,11 @@ class RestClient:
 
     def storage_plugins(self) -> list[dict[str, Any]]:
         response = self._request("GET", "/storage.json")
-        return redact(_json(response, self._config.url))
+        return redact(_json(response, _safe_url(self._config.url)))
 
     def cluster_status(self) -> dict[str, Any]:
-        cluster = _json(self._request("GET", "/cluster.json"), 
self._config.url)
-        status = _json(self._request("GET", "/status.json"), self._config.url)
+        cluster = _json(self._request("GET", "/cluster.json"), 
_safe_url(self._config.url))
+        status = _json(self._request("GET", "/status.json"), 
_safe_url(self._config.url))
         merged = dict(cluster) if isinstance(cluster, dict) else {"cluster": 
cluster}
         if isinstance(status, dict):
             merged.update(status)
@@ -609,7 +633,7 @@ class RestClient:
     def profiles(self, limit: int) -> list[dict[str, Any]]:
         limit = max(limit, 0)
         response = self._request("GET", "/profiles.json")
-        payload = _json(response, self._config.url)
+        payload = _json(response, _safe_url(self._config.url))
         if not isinstance(payload, dict):
             payload = {}
         running = payload.get("runningQueries") or []
@@ -619,7 +643,7 @@ class RestClient:
     def profile(self, query_id: str) -> dict[str, Any]:
         _check_query_id(query_id)
         response = self._request("GET", f"/profiles/{query_id}.json")
-        return _json(response, self._config.url)
+        return _json(response, _safe_url(self._config.url))
 
     def cancel_query(self, query_id: str) -> str:
         _check_query_id(query_id)
diff --git a/drill_mcp/config.py b/drill_mcp/config.py
index f413765..721310a 100644
--- a/drill_mcp/config.py
+++ b/drill_mcp/config.py
@@ -112,4 +112,13 @@ def load_config(
     try:
         return Config(**values)
     except ValidationError as exc:
-        raise ConfigError(str(exc)) from exc
+        # `str(exc)` embeds pydantic's `input_value=...` for every error,
+        # which echoes the offending config value verbatim -- including a
+        # password typed unquoted in YAML (e.g. `password: 12345`, which
+        # pydantic reports as `input_value=12345`). That must never reach
+        # stderr or a log. Rebuild the message from `loc`/`msg` only.
+        details = "; ".join(
+            f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" if 
err["loc"] else err["msg"]
+            for err in exc.errors()
+        )
+        raise ConfigError(details) from exc
diff --git a/drill_mcp/redact.py b/drill_mcp/redact.py
index f6ac0a4..150ea28 100644
--- a/drill_mcp/redact.py
+++ b/drill_mcp/redact.py
@@ -39,6 +39,32 @@ _SENSITIVE = re.compile(
     re.IGNORECASE,
 )
 
+# The key-based check above only catches secrets that live at a sensitive
+# *key*. A storage-plugin config routinely carries secrets embedded inside an
+# ordinary-looking *value* instead -- a JDBC/S3-style URL with userinfo
+# (`s3a://AKIA:secret@bucket`), or a connection string with a `password=`/
+# `secret=`/`token=` query parameter. Both shapes are real Drill storage
+# plugin configs, so string values are scrubbed too, not just keys.
+#
+# Matches "scheme://user:pass@" and keeps everything else (scheme, host,
+# path) intact -- only the credential pair between "//" and "@" is replaced.
+_URL_USERINFO = 
re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)[^/@\s]+:[^/@\s]*@")
+
+# Matches a `?key=value` or `&key=value` query parameter whose key looks like
+# a secret, and replaces only the value -- the `?`/`&` and key name are kept
+# so the rest of the string still parses as the same shape of URL.
+_QUERY_SECRET = re.compile(
+    r"(?P<prefix>[?&](?:password|secret|api_?key|token)=)[^&]*",
+    re.IGNORECASE,
+)
+
+
+def _scrub_value(value: str) -> str:
+    """Strip credentials embedded inside a string value, not just its key."""
+    value = _URL_USERINFO.sub(lambda m: f"{m.group('scheme')}{REDACTED}@", 
value)
+    value = _QUERY_SECRET.sub(lambda m: f"{m.group('prefix')}{REDACTED}", 
value)
+    return value
+
 
 def redact(value: Any) -> Any:
     """Return a copy of `value` with sensitive-looking values replaced."""
@@ -51,4 +77,6 @@ def redact(value: Any) -> Any:
         return [redact(item) for item in value]
     if isinstance(value, tuple):
         return tuple(redact(item) for item in value)
+    if isinstance(value, str):
+        return _scrub_value(value)
     return value
diff --git a/drill_mcp/server.py b/drill_mcp/server.py
index 2d9cae7..c4c4bf3 100644
--- a/drill_mcp/server.py
+++ b/drill_mcp/server.py
@@ -39,6 +39,7 @@ from mcp.server.mcpserver import MCPServer
 from .client_rest import DrillError, RestClient
 from .config import Config, ConfigError, load_config
 from .guard import Policy, PolicyError, check, is_show_command, matches_prefix
+from .redact import redact
 
 if TYPE_CHECKING:
     # Imported only for the type checker: build_client's lazy, in-function
@@ -77,6 +78,25 @@ class DrillTools:
         if matches_prefix(schema, self._policy.hidden_schemas):
             raise ToolError(f"schema '{schema}' is hidden by configuration")
 
+    def _profile_mentions_hidden_schema(self, profile: dict[str, Any]) -> bool:
+        """True if a profile's query text names a hidden schema.
+
+        Profiles are cluster-wide: `list_profiles`/`get_profile` surface
+        *other users'* query text, so a hidden schema name can leak out here
+        as data even though it was never queryable directly -- the one
+        enumeration path the guard and the metadata-tool filtering were
+        built to close. A case-insensitive substring match against the
+        query text is deliberately coarse (over-filtering a profile whose
+        SQL merely mentions a hidden schema's name in a string literal is an
+        acceptable false positive; missing one is a leak).
+        """
+        if not self._policy.hidden_schemas:
+            return False
+        query_text = str(profile.get("query") or "").lower()
+        if not query_text:
+            return False
+        return any(schema.lower() in query_text for schema in 
self._policy.hidden_schemas)
+
     def _visible(self, schema: str | None) -> bool:
         # Fail closed, not open: an item this function cannot identify (no
         # name at all) is filtered out rather than shown by default. Drill
@@ -200,18 +220,27 @@ class DrillTools:
         if not isinstance(limit, int) or isinstance(limit, bool):
             raise ToolError("limit must be an integer")
         try:
-            return self._require_management("profiles")(limit=limit)
+            profiles = self._require_management("profiles")(limit=limit)
         except DrillError as exc:
             raise ToolError(str(exc)) from exc
+        profiles = redact(profiles)
+        return [
+            p
+            for p in profiles
+            if isinstance(p, dict) and not 
self._profile_mentions_hidden_schema(p)
+        ]
 
     def get_profile(self, query_id: str) -> dict[str, Any]:
         """Fetch the full profile for one query id."""
         if not isinstance(query_id, str):
             raise ToolError("query_id must be a string")
         try:
-            return self._require_management("profile")(query_id)
+            profile = self._require_management("profile")(query_id)
         except DrillError as exc:
             raise ToolError(str(exc)) from exc
+        if isinstance(profile, dict) and 
self._profile_mentions_hidden_schema(profile):
+            raise ToolError(f"profile {query_id!r} references a hidden schema")
+        return redact(profile)
 
     def cancel_query(self, query_id: str) -> str:
         """Cancel a running query by its query id."""
@@ -301,10 +330,10 @@ def main(argv: list[str] | None = None) -> int:
     }
     try:
         config = load_config(args.config, overrides=overrides)
-    except ConfigError as exc:
+        build_server(config).run()
+    except (ConfigError, DrillError) as exc:
         print(f"drill-mcp: configuration error: {exc}", file=sys.stderr)
         return 1
-    build_server(config).run()
     return 0
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 4e0e66c..96a5ff5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,5 +53,3 @@ 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_client_jdbc.py b/tests/test_client_jdbc.py
index 02ae59f..29d5958 100644
--- a/tests/test_client_jdbc.py
+++ b/tests/test_client_jdbc.py
@@ -56,7 +56,7 @@ def fake_jaydebeapi(monkeypatch):
 def make_client(**overrides):
     overrides.setdefault("backend", "jdbc")
     overrides.setdefault("jdbc_driver_path", "/opt/drill-jdbc-all.jar")
-    return JdbcClient(load_config(overrides=overrides))
+    return JdbcClient(load_config(overrides=overrides, env={}))
 
 
 def test_clear_error_when_extra_is_not_installed(monkeypatch):
diff --git a/tests/test_client_rest.py b/tests/test_client_rest.py
index 983be61..0a0e939 100644
--- a/tests/test_client_rest.py
+++ b/tests/test_client_rest.py
@@ -39,7 +39,7 @@ BASE = "http://drill:8047";
 
 def make_client(**overrides):
     overrides.setdefault("url", BASE)
-    return RestClient(load_config(overrides=overrides))
+    return RestClient(load_config(overrides=overrides, env={}))
 
 
 class TestQuoting:
@@ -134,6 +134,24 @@ class TestQuery:
         )
         assert make_client().query("SELECT 1", max_rows=2).truncated is True
 
+    @respx.mock
+    def test_slices_rows_to_max_rows_even_if_drill_ignores_autolimit(self):
+        # `autoLimit` asks Drill to cap rows server-side, but the cap must
+        # not depend entirely on Drill honoring that field. Simulate Drill
+        # returning more rows than requested (e.g. an older Drill version, or
+        # autoLimit simply not being respected) and confirm the client still
+        # enforces the cap itself -- exactly like JdbcClient.query's
+        # fetchmany(max_rows).
+        respx.post(f"{BASE}/query.json").mock(
+            return_value=httpx.Response(
+                200,
+                json={"columns": ["a"], "rows": [{"a": str(i)} for i in 
range(10)]},
+            )
+        )
+        result = make_client().query("SELECT 1", max_rows=2)
+        assert len(result.rows) == 2
+        assert result.truncated is True
+
     @respx.mock
     def test_not_truncated_when_max_rows_is_zero(self):
         # 0 >= 0 would be a false "truncated" without the max_rows > 0 guard.
@@ -172,6 +190,27 @@ class TestQuery:
         assert BASE in str(exc.value)
         assert "s3cret" not in str(exc.value)
 
+    @respx.mock
+    def 
test_connection_failure_message_drops_a_password_embedded_in_the_url(self):
+        # Mirrors client_jdbc.py's
+        # test_jdbc_url_drops_userinfo_from_a_url_that_embeds_credentials --
+        # the REST backend must apply the same defense. config.url is
+        # free-form and unvalidated, so nothing stops
+        # DRILL_URL=http://alice:s3cret@drill:8047; every message that
+        # echoes config.url back to the model must not leak the password
+        # embedded there.
+        url = "http://alice:s3cret@drill:8047";
+        
respx.post(f"{url}/j_security_check").mock(side_effect=httpx.ConnectError("refused"))
+        
respx.post(f"{url}/query.json").mock(side_effect=httpx.ConnectError("refused"))
+        with pytest.raises(DrillError) as exc:
+            make_client(url=url, auth="basic", user="alice", 
password="s3cret").query(
+                "SELECT 1", max_rows=1
+            )
+        message = str(exc.value)
+        assert "s3cret" not in message
+        assert "alice" not in message
+        assert "drill:8047" in message
+
     @respx.mock
     def test_timeout_is_reported_clearly(self):
         
respx.post(f"{BASE}/query.json").mock(side_effect=httpx.ReadTimeout("slow"))
diff --git a/tests/test_config.py b/tests/test_config.py
index e7c1931..a40e59d 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -107,3 +107,14 @@ def test_config_is_immutable():
     cfg = load_config(env={})
     with pytest.raises(ValidationError, match="frozen"):
         cfg.url = "http://elsewhere:8047";
+
+
+def test_a_non_string_password_does_not_appear_in_the_error_message():
+    # pydantic's default ValidationError text embeds `input_value=...` for
+    # every error -- e.g. an unquoted `password: 12345` in YAML produces
+    # "input_value=12345" verbatim. ConfigError must not echo that: `main()`
+    # prints it to stderr, and any log capturing stderr would then carry the
+    # (would-be) password in plaintext.
+    with pytest.raises(ConfigError) as exc:
+        load_config(env={}, overrides={"password": 12345})
+    assert "12345" not in str(exc.value)
diff --git a/tests/test_redact.py b/tests/test_redact.py
index 7c0c64b..f195a20 100644
--- a/tests/test_redact.py
+++ b/tests/test_redact.py
@@ -48,6 +48,27 @@ def test_leaves_innocuous_keys_alone():
     }
 
 
+def test_redacts_userinfo_embedded_in_a_url_shaped_value():
+    # A secret can live inside an ordinary-looking value, not just behind a
+    # sensitive key -- e.g. a `connection` string embedding S3 credentials as
+    # userinfo. This used to survive untouched (the bug this test replaces
+    # `test_leaves_innocuous_keys_alone`'s old assertion for): the key-based
+    # check alone let `redact({"connection": "s3a://AKIA:secret@bucket"})`
+    # pass the secret straight through.
+    source = {"connection": "s3a://AKIA:supersecret@bucket"}
+    result = redact(source)
+    assert "supersecret" not in result["connection"]
+    assert "AKIA" not in result["connection"]
+    assert result["connection"] == "s3a://***REDACTED***@bucket"
+
+
+def test_redacts_password_query_parameter_embedded_in_a_url_shaped_value():
+    source = {"url": "jdbc:mysql://host/db?user=root&password=hunter2"}
+    result = redact(source)
+    assert "hunter2" not in result["url"]
+    assert result["url"] == 
"jdbc:mysql://host/db?user=root&password=***REDACTED***"
+
+
 def test_recurses_into_nested_dicts():
     source = {"config": {"aws": {"awsSecretAccessKey": "s"}}}
     assert redact(source)["config"]["aws"]["awsSecretAccessKey"] == REDACTED
diff --git a/tests/test_server.py b/tests/test_server.py
index 3678132..c323598 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -29,7 +29,7 @@ from drill_mcp.server import DrillTools, ToolError, 
build_client, build_server
 
 def make_tools(client=None, **overrides):
     client = client or MagicMock()
-    return DrillTools(load_config(overrides=overrides), client)
+    return DrillTools(load_config(overrides=overrides, env={}), client)
 
 
 class TestRunQuery:
@@ -223,6 +223,43 @@ class TestManagementTools:
         client.profile.return_value = {"queryId": "abc"}
         assert make_tools(client).get_profile("abc")["queryId"] == "abc"
 
+    def test_get_profile_redacts_secret_looking_keys(self):
+        # Profiles are cluster-wide: a full profile embeds Drill's
+        # serialized physical plan, which for JDBC/HTTP plugins can carry
+        # plugin configuration (passwords, tokens). This must go through the
+        # same redaction as list_storage_plugins, not be returned unmodified.
+        client = MagicMock()
+        client.profile.return_value = {"queryId": "abc", "password": "hunter2"}
+        result = make_tools(client).get_profile("abc")
+        assert result["password"] == "***REDACTED***"
+        assert result["queryId"] == "abc"
+
+    def 
test_get_profile_is_refused_when_its_query_text_names_a_hidden_schema(self):
+        # A profile carries the query TEXT of whatever user ran it -- other
+        # users' queries, not just the caller's own. A hidden schema's name
+        # can leak out here as data even though it is unreachable directly,
+        # which is exactly the enumeration path the guard and hidden-schema
+        # filtering elsewhere were built to close.
+        client = MagicMock()
+        client.profile.return_value = {"queryId": "abc", "query": "SELECT * 
FROM sys.options"}
+        with pytest.raises(ToolError, match="hidden"):
+            make_tools(client, hidden_schemas=["sys"]).get_profile("abc")
+
+    def test_list_profiles_redacts_secret_looking_keys(self):
+        client = MagicMock()
+        client.profiles.return_value = [{"queryId": "abc", "password": 
"hunter2"}]
+        result = make_tools(client).list_profiles()
+        assert result[0]["password"] == "***REDACTED***"
+
+    def 
test_list_profiles_drops_entries_whose_query_text_names_a_hidden_schema(self):
+        client = MagicMock()
+        client.profiles.return_value = [
+            {"queryId": "abc", "query": "SELECT * FROM sys.options"},
+            {"queryId": "def", "query": "SELECT * FROM dfs.tmp.x"},
+        ]
+        result = make_tools(client, hidden_schemas=["sys"]).list_profiles()
+        assert [p["queryId"] for p in result] == ["def"]
+
     def test_cancel_query(self):
         client = MagicMock()
         client.cancel_query.return_value = "Cancelled"
@@ -540,14 +577,14 @@ class TestShowFiltering:
 
 class TestWiring:
     def test_rest_backend_builds_a_rest_client(self):
-        assert isinstance(build_client(load_config()), RestClient)
+        assert isinstance(build_client(load_config(env={})), RestClient)
 
     def test_jdbc_backend_builds_a_jdbc_client(self):
-        cfg = load_config(overrides={"backend": "jdbc", "jdbc_driver_path": 
"/x.jar"})
+        cfg = load_config(overrides={"backend": "jdbc", "jdbc_driver_path": 
"/x.jar"}, env={})
         assert isinstance(build_client(cfg), JdbcClient)
 
     def test_all_tools_are_registered(self):
-        server = build_server(load_config())
+        server = build_server(load_config(env={}))
         names = {tool.name for tool in server._tool_manager.list_tools()}
         assert names == {
             "run_query",
@@ -562,11 +599,11 @@ class TestWiring:
         }
 
     def test_every_tool_has_a_description(self):
-        server = build_server(load_config())
+        server = build_server(load_config(env={}))
         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())
+        server = build_server(load_config(env={}))
         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"}
@@ -574,7 +611,7 @@ class TestWiring:
 
     def test_no_registered_tool_accepts_a_credential_argument(self):
         """Credentials come from config or environment only, never a tool 
argument."""
-        server = build_server(load_config())
+        server = build_server(load_config(env={}))
         credential_words = {"user", "password", "username", "passwd", 
"secret", "token", "credential"}
         for tool in server._tool_manager.list_tools():
             params = set(tool.parameters.get("properties", {}))

Reply via email to