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.
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/CHANGELOG.md +6 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/PKG-INFO +3 -1
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/README.md +2 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/pyproject.toml +1 -1
- mcp_read_only_sql-0.2.6/src/mcp_read_only_sql/cli_binaries.py +158 -0
- {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
- {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
- {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
- mcp_read_only_sql-0.2.6/tests/test_cli_binaries.py +93 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.github/workflows/publish.yml +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.github/workflows/test.yml +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.gitignore +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/.mcp.json +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/AGENTS.md +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/LICENSE +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/RELEASING.md +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/conftest.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/clickhouse/init/01_init.sql +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/postgres/init/01_schema.sql +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/postgres/init/02_data.sql +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker/ssh/Dockerfile +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/docker-compose.yml +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/justfile +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/pytest.ini +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/run_tests.sh +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/__init__.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/__init__.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/connection.py +0 -0
- {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
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/loader.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/parser.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base.py +0 -0
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/server.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/__init__.py +0 -0
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/KNOWN_ISSUES.md +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/README.md +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/__init__.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/conftest.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/conftest_new.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/connections-test.yaml +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/pytest_plugins.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/sql_statement_lists.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_ssh_tunnels.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_system_ssh.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_cli_versions.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_clickhouse_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_config_connection.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_config_parser.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connection_utils.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connections_reload.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_connector_implementations.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_dbeaver_import.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_docker_connectivity.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_error_handling.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_host_mapping_flow.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_limits.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_mcp_protocol.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_mcp_server.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_postgresql_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_process_cleanup.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_result_serialization.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_run_query_file_output.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_layers.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly_integration.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_serialization_fallback.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_server.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_server_selection.py +0 -0
- {mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_ssh_timeout.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
+
)
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base_cli.py
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/__init__.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/connection.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/dbeaver_import.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connections.yaml.sample
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/__init__.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_connection.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/validate_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/connection_utils.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/json_serializer.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/sql_guard.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/timeout_wrapper.py
RENAMED
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/tsv_formatter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_read_only_sql-0.2.5 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|