mcp-read-only-sql 0.2.5__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/CHANGELOG.md +6 -0
  2. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/PKG-INFO +3 -1
  3. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/README.md +2 -0
  4. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/pyproject.toml +1 -1
  5. mcp_read_only_sql-0.2.6/src/mcp_read_only_sql/cli_binaries.py +158 -0
  6. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base_cli.py +21 -0
  7. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +4 -2
  8. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/cli.py +4 -2
  9. mcp_read_only_sql-0.2.6/tests/test_cli_binaries.py +93 -0
  10. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.github/workflows/publish.yml +0 -0
  11. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.github/workflows/test.yml +0 -0
  12. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.gitignore +0 -0
  13. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.mcp.json +0 -0
  14. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/AGENTS.md +0 -0
  15. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/LICENSE +0 -0
  16. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
  17. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/RELEASING.md +0 -0
  18. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/conftest.py +0 -0
  19. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/connections.yaml.sample +0 -0
  20. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/clickhouse/init/01_init.sql +0 -0
  21. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/postgres/init/01_schema.sql +0 -0
  22. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/postgres/init/02_data.sql +0 -0
  23. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/ssh/Dockerfile +0 -0
  24. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker-compose.yml +0 -0
  25. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/justfile +0 -0
  26. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/pytest.ini +0 -0
  27. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/run_tests.sh +0 -0
  28. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/__init__.py +0 -0
  29. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/__init__.py +0 -0
  30. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/connection.py +0 -0
  31. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/dbeaver_import.py +0 -0
  32. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/loader.py +0 -0
  33. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/parser.py +0 -0
  34. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
  35. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
  36. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base.py +0 -0
  37. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
  38. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/python.py +0 -0
  39. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
  40. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/python.py +0 -0
  41. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/runtime_paths.py +0 -0
  42. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/server.py +0 -0
  43. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/__init__.py +0 -0
  44. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
  45. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py +0 -0
  46. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
  47. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/__init__.py +0 -0
  48. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
  49. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/json_serializer.py +0 -0
  50. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
  51. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel.py +0 -0
  52. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py +0 -0
  53. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/timeout_wrapper.py +0 -0
  54. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
  55. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/KNOWN_ISSUES.md +0 -0
  56. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/README.md +0 -0
  57. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/__init__.py +0 -0
  58. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/conftest.py +0 -0
  59. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/conftest_new.py +0 -0
  60. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/connections-test.yaml +0 -0
  61. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/docker_test_config.py +0 -0
  62. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/pytest_plugins.py +0 -0
  63. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/sql_statement_lists.py +0 -0
  64. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_ssh_tunnels.py +0 -0
  65. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_system_ssh.py +0 -0
  66. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_versions.py +0 -0
  67. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_clickhouse_cli_fallback.py +0 -0
  68. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_config_connection.py +0 -0
  69. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_config_parser.py +0 -0
  70. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connection_utils.py +0 -0
  71. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connections_reload.py +0 -0
  72. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connector_implementations.py +0 -0
  73. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_dbeaver_import.py +0 -0
  74. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_docker_connectivity.py +0 -0
  75. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_docker_test_config.py +0 -0
  76. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_error_handling.py +0 -0
  77. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_host_mapping_flow.py +0 -0
  78. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_limits.py +0 -0
  79. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_mcp_protocol.py +0 -0
  80. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_mcp_server.py +0 -0
  81. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_postgresql_cli_fallback.py +0 -0
  82. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_process_cleanup.py +0 -0
  83. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_result_serialization.py +0 -0
  84. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_run_query_file_output.py +0 -0
  85. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_runtime_paths.py +0 -0
  86. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_layers.py +0 -0
  87. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly.py +0 -0
  88. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly_integration.py +0 -0
  89. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_serialization_fallback.py +0 -0
  90. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_server.py +0 -0
  91. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_server_selection.py +0 -0
  92. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_ssh_timeout.py +0 -0
  93. {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_ssh_tunnels.py +0 -0
@@ -7,6 +7,12 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.6] - 2026-06-08
11
+
12
+ ### Fixed
13
+
14
+ - Resolved the `psql` / `clickhouse-client` CLI binaries through an explicit lookup (env override → `PATH` → OS-aware fallback) instead of relying solely on `PATH`. Homebrew keg-only `libpq` installs on macOS, where `psql` is not symlinked onto `PATH`, now work without manual `PATH` setup. The resolved path can be pinned with `MCP_READ_ONLY_SQL_PSQL_PATH` / `MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH`, and is cached per connector.
15
+
10
16
  ## [0.2.5] - 2026-04-21
11
17
 
12
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-read-only-sql
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: MCP server for read-only SQL queries supporting PostgreSQL and ClickHouse
5
5
  Project-URL: Homepage, https://github.com/lukleh/mcp-read-only-sql
6
6
  Project-URL: Repository, https://github.com/lukleh/mcp-read-only-sql
@@ -86,6 +86,8 @@ See [READ_ONLY_ENFORCEMENT_MATRIX.md](READ_ONLY_ENFORCEMENT_MATRIX.md) for a sta
86
86
 
87
87
  Install the optional CLI binaries with your operating system's package manager or the official PostgreSQL / ClickHouse packages for your environment.
88
88
 
89
+ The CLI binaries are located via the override environment variable (`MCP_READ_ONLY_SQL_PSQL_PATH` / `MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH`) if set, then `PATH`, then OS-specific fallback locations (e.g. Homebrew keg-only `libpq` on macOS, packaged PostgreSQL directories on Linux). If a binary is installed somewhere not on `PATH`, set the matching variable to its full path.
90
+
89
91
  The SQL package keeps both execution models first-class:
90
92
 
91
93
  - `implementation: cli` uses the official database client binaries you already trust in operations.
@@ -51,6 +51,8 @@ See [READ_ONLY_ENFORCEMENT_MATRIX.md](READ_ONLY_ENFORCEMENT_MATRIX.md) for a sta
51
51
 
52
52
  Install the optional CLI binaries with your operating system's package manager or the official PostgreSQL / ClickHouse packages for your environment.
53
53
 
54
+ The CLI binaries are located via the override environment variable (`MCP_READ_ONLY_SQL_PSQL_PATH` / `MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH`) if set, then `PATH`, then OS-specific fallback locations (e.g. Homebrew keg-only `libpq` on macOS, packaged PostgreSQL directories on Linux). If a binary is installed somewhere not on `PATH`, set the matching variable to its full path.
55
+
54
56
  The SQL package keeps both execution models first-class:
55
57
 
56
58
  - `implementation: cli` uses the official database client binaries you already trust in operations.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-read-only-sql"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  description = "MCP server for read-only SQL queries supporting PostgreSQL and ClickHouse"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,158 @@
1
+ """Resolution of external CLI client binaries (psql, clickhouse-client).
2
+
3
+ The CLI connectors shell out to a database client. Relying on a bare command
4
+ name only works when the client happens to be on ``PATH``; a common failure is
5
+ macOS Homebrew, where ``psql`` ships with the keg-only ``libpq`` formula and is
6
+ not symlinked onto the default ``PATH`` (the server then fails with
7
+ "psql: command not found").
8
+
9
+ ``resolve_cli_binary`` locates the client without hardcoding install locations
10
+ as the primary mechanism. Resolution order:
11
+
12
+ 1. Explicit override via ``MCP_READ_ONLY_SQL_<BINARY>_PATH`` (matches the
13
+ project's existing ``MCP_READ_ONLY_SQL_*`` env convention).
14
+ 2. :func:`shutil.which` -- honors ``PATH`` (the normal case on Linux and any
15
+ correctly configured environment).
16
+ 3. OS-aware fallback directories, queried dynamically where possible:
17
+ - macOS: ask Homebrew (``brew --prefix``) where keg-only ``libpq`` / the
18
+ ``postgresql`` formulae live, plus the standard Homebrew opt dirs.
19
+ - Linux: standard packaged PostgreSQL layouts (Debian/Ubuntu, PGDG/RHEL).
20
+
21
+ If every step fails, a :class:`FileNotFoundError` is raised naming the override
22
+ env var so the user can point at the binary explicitly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import glob
28
+ import os
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+
33
+ from .runtime_paths import ENV_PREFIX
34
+
35
+ # Upper bound on the optional `brew --prefix` probe so a misbehaving brew can
36
+ # never hang query execution.
37
+ _BREW_PROBE_TIMEOUT = 5 # seconds
38
+
39
+ # Homebrew formulae that provide each client, in preference order.
40
+ _BREW_FORMULAE: dict[str, list[str]] = {
41
+ "psql": ["libpq", "postgresql@18", "postgresql@17", "postgresql@16", "postgresql"],
42
+ "clickhouse-client": ["clickhouse"],
43
+ }
44
+
45
+
46
+ def env_var_for(binary: str) -> str:
47
+ """Return the override env var name for ``binary``.
48
+
49
+ ``psql`` -> ``MCP_READ_ONLY_SQL_PSQL_PATH``;
50
+ ``clickhouse-client`` -> ``MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH``.
51
+ """
52
+ slug = binary.upper().replace("-", "_")
53
+ return f"{ENV_PREFIX}_{slug}_PATH"
54
+
55
+
56
+ def _is_executable(path: str) -> bool:
57
+ return bool(path) and os.path.isfile(path) and os.access(path, os.X_OK)
58
+
59
+
60
+ def _brew_prefix(formula: str) -> str | None:
61
+ """Return the Homebrew prefix for ``formula``, or ``None`` if unavailable.
62
+
63
+ Note: this uses a blocking ``subprocess.run``. ``resolve_cli_binary`` is
64
+ synchronous but is called from within the connectors' async ``_run_query``,
65
+ so this probe blocks the event loop for its duration (bounded by
66
+ ``_BREW_PROBE_TIMEOUT`` per call). It only runs on the fallback path (binary
67
+ not pinned and not on PATH, macOS only) and ``BaseCLIConnector`` caches the
68
+ resolved path per connector, so in practice it runs at most once per
69
+ connector rather than per query. If that ever becomes a problem (e.g. many
70
+ connectors resolving concurrently), move the call onto a thread via
71
+ ``asyncio.to_thread`` or lower ``_BREW_PROBE_TIMEOUT``.
72
+ """
73
+ brew = shutil.which("brew")
74
+ if not brew:
75
+ return None
76
+ try:
77
+ result = subprocess.run(
78
+ [brew, "--prefix", formula],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=_BREW_PROBE_TIMEOUT,
82
+ )
83
+ except (OSError, subprocess.SubprocessError):
84
+ return None
85
+ if result.returncode != 0:
86
+ return None
87
+ prefix = result.stdout.strip()
88
+ return prefix or None
89
+
90
+
91
+ def _candidate_dirs(binary: str) -> list[str]:
92
+ """OS-aware fallback directories to search for ``binary``.
93
+
94
+ Evaluated only when the binary is neither pinned via env var nor found on
95
+ ``PATH``. Homebrew prefixes are resolved dynamically; the static entries are
96
+ last-resort best effort.
97
+ """
98
+ dirs: list[str] = []
99
+
100
+ if sys.platform == "darwin":
101
+ for formula in _BREW_FORMULAE.get(binary, []):
102
+ prefix = _brew_prefix(formula)
103
+ if prefix:
104
+ dirs.append(os.path.join(prefix, "bin"))
105
+ if binary == "psql":
106
+ # Standard Homebrew keg-only libpq locations (Apple Silicon, Intel).
107
+ dirs += ["/opt/homebrew/opt/libpq/bin", "/usr/local/opt/libpq/bin"]
108
+ elif sys.platform.startswith("linux"):
109
+ if binary == "psql":
110
+ # Debian/Ubuntu (postgresql-client-NN) and PGDG/RHEL layouts;
111
+ # newest version dir first. clickhouse-client has no equivalent
112
+ # fallback: its distro package installs onto PATH, so which() above
113
+ # already covers it.
114
+ dirs += sorted(glob.glob("/usr/lib/postgresql/*/bin"), reverse=True)
115
+ dirs += sorted(glob.glob("/usr/pgsql-*/bin"), reverse=True)
116
+
117
+ return dirs
118
+
119
+
120
+ def resolve_cli_binary(binary: str) -> str:
121
+ """Resolve the absolute path to a CLI client binary.
122
+
123
+ Args:
124
+ binary: The client command name, e.g. ``"psql"`` or
125
+ ``"clickhouse-client"``.
126
+
127
+ Returns:
128
+ Absolute path to an executable.
129
+
130
+ Raises:
131
+ FileNotFoundError: If the binary cannot be located. The message names
132
+ the override env var.
133
+ """
134
+ env_var = env_var_for(binary)
135
+
136
+ override = os.environ.get(env_var)
137
+ if override:
138
+ expanded = os.path.expanduser(override)
139
+ if _is_executable(expanded):
140
+ return expanded
141
+ raise FileNotFoundError(
142
+ f"{binary}: {env_var} is set to '{override}', but that is not an "
143
+ f"executable file."
144
+ )
145
+
146
+ found = shutil.which(binary)
147
+ if found:
148
+ return found
149
+
150
+ for directory in _candidate_dirs(binary):
151
+ candidate = os.path.join(directory, binary)
152
+ if _is_executable(candidate):
153
+ return candidate
154
+
155
+ raise FileNotFoundError(
156
+ f"{binary}: command not found. Install the client tools, add them to "
157
+ f"PATH, or set {env_var} to the full path of the {binary} executable."
158
+ )
@@ -8,6 +8,7 @@ import logging
8
8
 
9
9
  from .base import BaseConnector
10
10
  from ..config import Connection
11
+ from ..cli_binaries import resolve_cli_binary
11
12
  from ..utils.ssh_tunnel_cli import CLISSHTunnel
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -19,6 +20,26 @@ class BaseCLIConnector(BaseConnector):
19
20
  def __init__(self, connection: Connection):
20
21
  super().__init__(connection)
21
22
  self._ssh_tunnel = None
23
+ # Resolved CLI client paths, cached per connector instance. Connectors
24
+ # are rebuilt on config reload, so this re-resolves after a reload while
25
+ # avoiding a lookup (and a possible `brew --prefix` probe) per query.
26
+ self._binary_cache: dict[str, str] = {}
27
+
28
+ def _resolve_binary(self, name: str) -> str:
29
+ """Resolve and cache the absolute path to a CLI client binary.
30
+
31
+ Called synchronously from the async ``_run_query``. On the macOS
32
+ fallback path resolution may run a blocking ``brew --prefix`` probe (see
33
+ ``cli_binaries._brew_prefix``); caching the result here keeps that to at
34
+ most once per connector instead of once per query. Failures are not
35
+ cached, so resolution retries on the next query (e.g. if the client is
36
+ installed mid-session).
37
+ """
38
+ cached = self._binary_cache.get(name)
39
+ if cached is None:
40
+ cached = resolve_cli_binary(name)
41
+ self._binary_cache[name] = cached
42
+ return cached
22
43
 
23
44
  @asynccontextmanager
24
45
  async def _get_ssh_tunnel(self, server: Optional[str] = None):
@@ -110,9 +110,11 @@ class ClickHouseCLIConnector(BaseCLIConnector):
110
110
  # Use specified database or configured database (validated)
111
111
  db_name = self._resolve_database(database)
112
112
 
113
- # Build clickhouse-client command with read-only enforcement
113
+ # Build clickhouse-client command with read-only enforcement.
114
+ # Resolve the client binary explicitly so installs that are not on
115
+ # PATH still work (mirrors the psql connector).
114
116
  cmd = [
115
- "clickhouse-client",
117
+ self._resolve_binary("clickhouse-client"),
116
118
  "--host",
117
119
  host,
118
120
  "--port",
@@ -75,9 +75,11 @@ class PostgreSQLCLIConnector(BaseCLIConnector):
75
75
  COMMIT;
76
76
  """
77
77
 
78
- # Build psql command with individual parameters
78
+ # Build psql command with individual parameters.
79
+ # Resolve the client binary explicitly so installs that are not on
80
+ # PATH (e.g. Homebrew keg-only libpq on macOS) still work.
79
81
  cmd = [
80
- "psql",
82
+ self._resolve_binary("psql"),
81
83
  "--single-transaction",
82
84
  "-v",
83
85
  "ON_ERROR_STOP=1",
@@ -0,0 +1,93 @@
1
+ import stat
2
+
3
+ import pytest
4
+
5
+ from mcp_read_only_sql import cli_binaries
6
+ from mcp_read_only_sql.cli_binaries import env_var_for, resolve_cli_binary
7
+
8
+
9
+ def _make_executable(path):
10
+ path.write_text("#!/bin/sh\n")
11
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
12
+ return str(path)
13
+
14
+
15
+ def test_env_var_for_naming():
16
+ assert env_var_for("psql") == "MCP_READ_ONLY_SQL_PSQL_PATH"
17
+ assert (
18
+ env_var_for("clickhouse-client") == "MCP_READ_ONLY_SQL_CLICKHOUSE_CLIENT_PATH"
19
+ )
20
+
21
+
22
+ def test_env_override_returns_pinned_path(monkeypatch, tmp_path):
23
+ binary = _make_executable(tmp_path / "psql")
24
+ monkeypatch.setenv("MCP_READ_ONLY_SQL_PSQL_PATH", binary)
25
+
26
+ assert resolve_cli_binary("psql") == binary
27
+
28
+
29
+ def test_env_override_expands_user(monkeypatch, tmp_path):
30
+ binary = _make_executable(tmp_path / "psql")
31
+ monkeypatch.setenv("HOME", str(tmp_path))
32
+ monkeypatch.setenv("MCP_READ_ONLY_SQL_PSQL_PATH", "~/psql")
33
+
34
+ assert resolve_cli_binary("psql") == binary
35
+
36
+
37
+ def test_env_override_non_executable_raises(monkeypatch, tmp_path):
38
+ bogus = tmp_path / "not-exec"
39
+ bogus.write_text("")
40
+ monkeypatch.setenv("MCP_READ_ONLY_SQL_PSQL_PATH", str(bogus))
41
+
42
+ with pytest.raises(FileNotFoundError) as exc:
43
+ resolve_cli_binary("psql")
44
+ assert "MCP_READ_ONLY_SQL_PSQL_PATH" in str(exc.value)
45
+
46
+
47
+ def test_resolves_via_which(monkeypatch):
48
+ monkeypatch.delenv("MCP_READ_ONLY_SQL_PSQL_PATH", raising=False)
49
+ monkeypatch.setattr(
50
+ cli_binaries.shutil,
51
+ "which",
52
+ lambda name: "/usr/bin/psql" if name == "psql" else None,
53
+ )
54
+
55
+ assert resolve_cli_binary("psql") == "/usr/bin/psql"
56
+
57
+
58
+ def test_falls_back_to_candidate_dir(monkeypatch, tmp_path):
59
+ binary = _make_executable(tmp_path / "psql")
60
+ monkeypatch.delenv("MCP_READ_ONLY_SQL_PSQL_PATH", raising=False)
61
+ monkeypatch.setattr(cli_binaries.shutil, "which", lambda name: None)
62
+ monkeypatch.setattr(cli_binaries, "_candidate_dirs", lambda b: [str(tmp_path)])
63
+
64
+ assert resolve_cli_binary("psql") == binary
65
+
66
+
67
+ def test_not_found_raises_with_env_hint(monkeypatch):
68
+ monkeypatch.delenv("MCP_READ_ONLY_SQL_PSQL_PATH", raising=False)
69
+ monkeypatch.setattr(cli_binaries.shutil, "which", lambda name: None)
70
+ monkeypatch.setattr(cli_binaries, "_candidate_dirs", lambda b: [])
71
+
72
+ with pytest.raises(FileNotFoundError) as exc:
73
+ resolve_cli_binary("psql")
74
+ message = str(exc.value)
75
+ assert "psql: command not found" in message
76
+ assert "MCP_READ_ONLY_SQL_PSQL_PATH" in message
77
+
78
+
79
+ def test_brew_prefix_used_for_candidate_dirs(monkeypatch, tmp_path):
80
+ """On macOS the libpq Homebrew prefix should be probed for psql."""
81
+ prefix_bin = tmp_path / "opt" / "libpq" / "bin"
82
+ prefix_bin.mkdir(parents=True)
83
+ _make_executable(prefix_bin / "psql")
84
+
85
+ monkeypatch.setattr(cli_binaries.sys, "platform", "darwin")
86
+ monkeypatch.setattr(
87
+ cli_binaries,
88
+ "_brew_prefix",
89
+ lambda formula: str(tmp_path / "opt" / "libpq") if formula == "libpq" else None,
90
+ )
91
+
92
+ dirs = cli_binaries._candidate_dirs("psql")
93
+ assert str(prefix_bin) in dirs