mcp-read-only-sql 0.2.2__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 (94) hide show
  1. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/CHANGELOG.md +18 -0
  2. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/PKG-INFO +4 -1
  3. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/README.md +2 -0
  4. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/pyproject.toml +9 -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.6/src/mcp_read_only_sql/config/__init__.py +14 -0
  7. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/loader.py +44 -22
  8. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base_cli.py +21 -0
  9. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +4 -2
  10. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/cli.py +4 -2
  11. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/server.py +98 -44
  12. mcp_read_only_sql-0.2.6/tests/test_cli_binaries.py +93 -0
  13. mcp_read_only_sql-0.2.6/tests/test_connections_reload.py +350 -0
  14. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_run_query_file_output.py +1 -0
  15. mcp_read_only_sql-0.2.2/src/mcp_read_only_sql/config/__init__.py +0 -8
  16. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.github/workflows/publish.yml +0 -0
  17. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.github/workflows/test.yml +0 -0
  18. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.gitignore +0 -0
  19. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.mcp.json +0 -0
  20. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/AGENTS.md +0 -0
  21. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/LICENSE +0 -0
  22. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
  23. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/RELEASING.md +0 -0
  24. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/conftest.py +0 -0
  25. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/connections.yaml.sample +0 -0
  26. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/clickhouse/init/01_init.sql +0 -0
  27. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/postgres/init/01_schema.sql +0 -0
  28. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/postgres/init/02_data.sql +0 -0
  29. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/ssh/Dockerfile +0 -0
  30. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker-compose.yml +0 -0
  31. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/justfile +0 -0
  32. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/pytest.ini +0 -0
  33. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/run_tests.sh +0 -0
  34. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/__init__.py +0 -0
  35. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/connection.py +0 -0
  36. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/dbeaver_import.py +0 -0
  37. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/parser.py +0 -0
  38. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
  39. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
  40. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base.py +0 -0
  41. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
  42. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/clickhouse/python.py +0 -0
  43. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
  44. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/postgresql/python.py +0 -0
  45. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/runtime_paths.py +0 -0
  46. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/__init__.py +0 -0
  47. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
  48. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py +0 -0
  49. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
  50. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/__init__.py +0 -0
  51. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
  52. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/json_serializer.py +0 -0
  53. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
  54. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel.py +0 -0
  55. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py +0 -0
  56. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/timeout_wrapper.py +0 -0
  57. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
  58. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/KNOWN_ISSUES.md +0 -0
  59. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/README.md +0 -0
  60. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/__init__.py +0 -0
  61. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/conftest.py +0 -0
  62. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/conftest_new.py +0 -0
  63. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/connections-test.yaml +0 -0
  64. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/docker_test_config.py +0 -0
  65. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/pytest_plugins.py +0 -0
  66. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/sql_statement_lists.py +0 -0
  67. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_ssh_tunnels.py +0 -0
  68. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_system_ssh.py +0 -0
  69. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_versions.py +0 -0
  70. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_clickhouse_cli_fallback.py +0 -0
  71. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_config_connection.py +0 -0
  72. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_config_parser.py +0 -0
  73. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_connection_utils.py +0 -0
  74. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_connector_implementations.py +0 -0
  75. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_dbeaver_import.py +0 -0
  76. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_docker_connectivity.py +0 -0
  77. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_docker_test_config.py +0 -0
  78. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_error_handling.py +0 -0
  79. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_host_mapping_flow.py +0 -0
  80. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_limits.py +0 -0
  81. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_mcp_protocol.py +0 -0
  82. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_mcp_server.py +0 -0
  83. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_postgresql_cli_fallback.py +0 -0
  84. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_process_cleanup.py +0 -0
  85. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_result_serialization.py +0 -0
  86. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_runtime_paths.py +0 -0
  87. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_layers.py +0 -0
  88. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly.py +0 -0
  89. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly_integration.py +0 -0
  90. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_serialization_fallback.py +0 -0
  91. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_server.py +0 -0
  92. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_server_selection.py +0 -0
  93. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_ssh_timeout.py +0 -0
  94. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_ssh_tunnels.py +0 -0
@@ -7,6 +7,24 @@ 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
+
16
+ ## [0.2.5] - 2026-04-21
17
+
18
+ ### Added
19
+
20
+ - Added hot-reload regression tests covering connection add/change/remove flows, invalid live edits, and config changes that happen during a reload attempt.
21
+
22
+ ### Fixed
23
+
24
+ - Reloaded `connections.yaml` automatically before both `list_connections` and `run_query_read_only`, without requiring an MCP server restart.
25
+ - Kept hot-reload state atomic by building connectors from a single file snapshot and only storing a config marker for the exact snapshot that was actually loaded.
26
+ - Preserved the last known good connections when live config edits are invalid or the config file is temporarily missing, while continuing to retry reloads on later tool calls.
27
+
10
28
  ## [0.2.2] - 2026-04-03
11
29
 
12
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-read-only-sql
3
- Version: 0.2.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
@@ -30,6 +30,7 @@ Requires-Dist: pytest-timeout>=2.2.0; extra == 'dev'
30
30
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
31
31
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
32
32
  Requires-Dist: ty>=0.0.28; extra == 'dev'
33
+ Requires-Dist: vulture>=2.16; extra == 'dev'
33
34
  Description-Content-Type: text/markdown
34
35
 
35
36
  # MCP Read-Only SQL Server
@@ -85,6 +86,8 @@ See [READ_ONLY_ENFORCEMENT_MATRIX.md](READ_ONLY_ENFORCEMENT_MATRIX.md) for a sta
85
86
 
86
87
  Install the optional CLI binaries with your operating system's package manager or the official PostgreSQL / ClickHouse packages for your environment.
87
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
+
88
91
  The SQL package keeps both execution models first-class:
89
92
 
90
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.2"
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"
@@ -40,6 +40,7 @@ dev = [
40
40
  "black>=23.0.0",
41
41
  "ruff>=0.1.0",
42
42
  "ty>=0.0.28",
43
+ "vulture>=2.16",
43
44
  ]
44
45
 
45
46
  [build-system]
@@ -54,3 +55,10 @@ packages = ["src/mcp_read_only_sql"]
54
55
 
55
56
  [tool.ty.src]
56
57
  include = ["src"]
58
+
59
+ [tool.vulture]
60
+ paths = ["src", "tests"]
61
+ exclude = [".venv", "venv", "build", "dist", "node_modules", "__pycache__", "site-packages"]
62
+ ignore_decorators = ["@*.tool*", "@pytest.fixture"]
63
+ ignore_names = ["pytest_*"]
64
+ min_confidence = 80
@@ -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
+ )
@@ -0,0 +1,14 @@
1
+ """
2
+ Configuration module for connection management
3
+ """
4
+
5
+ from .connection import Connection, Server, SSHTunnelConfig
6
+ from .loader import load_connections, load_connections_from_text
7
+
8
+ __all__ = [
9
+ "Connection",
10
+ "Server",
11
+ "SSHTunnelConfig",
12
+ "load_connections",
13
+ "load_connections_from_text",
14
+ ]
@@ -8,29 +8,13 @@ import yaml
8
8
  from .connection import Connection
9
9
 
10
10
 
11
- def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
12
- """
13
- Load and validate all connections from YAML configuration file.
14
-
15
- Args:
16
- yaml_path: Path to connections.yaml file
17
-
18
- Returns:
19
- Dictionary mapping connection name to Connection object
20
-
21
- Raises:
22
- FileNotFoundError: If YAML file doesn't exist
23
- ValueError: If configuration is invalid (includes all validation errors)
24
- """
25
- yaml_file = Path(yaml_path).expanduser()
26
- if not yaml_file.exists():
27
- raise FileNotFoundError(f"Configuration file not found: {yaml_path}")
28
-
29
- with open(yaml_file, encoding="utf-8") as f:
30
- raw_configs = yaml.safe_load(f)
31
-
11
+ def _build_connections_from_raw_configs(
12
+ raw_configs: Any, source: str | Path
13
+ ) -> Dict[str, Connection]:
14
+ """Validate parsed YAML data and return Connection objects."""
15
+ source_name = str(source)
32
16
  if not raw_configs:
33
- raise ValueError(f"Configuration file is empty: {yaml_path}")
17
+ raise ValueError(f"Configuration file is empty: {source_name}")
34
18
 
35
19
  if not isinstance(raw_configs, list):
36
20
  raise ValueError("Configuration file must contain a list of connections")
@@ -64,3 +48,41 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
64
48
  raise ValueError(error_msg)
65
49
 
66
50
  return connections
51
+
52
+
53
+ def load_connections_from_text(
54
+ yaml_text: str, source: str | Path = "<memory>"
55
+ ) -> Dict[str, Connection]:
56
+ """
57
+ Load and validate connections from a YAML text snapshot.
58
+
59
+ Args:
60
+ yaml_text: Raw YAML document content
61
+ source: Source label used in validation errors
62
+ """
63
+ raw_configs = yaml.safe_load(yaml_text)
64
+ return _build_connections_from_raw_configs(raw_configs, source)
65
+
66
+
67
+ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
68
+ """
69
+ Load and validate all connections from YAML configuration file.
70
+
71
+ Args:
72
+ yaml_path: Path to connections.yaml file
73
+
74
+ Returns:
75
+ Dictionary mapping connection name to Connection object
76
+
77
+ Raises:
78
+ FileNotFoundError: If YAML file doesn't exist
79
+ ValueError: If configuration is invalid (includes all validation errors)
80
+ """
81
+ yaml_file = Path(yaml_path).expanduser()
82
+ if not yaml_file.exists():
83
+ raise FileNotFoundError(f"Configuration file not found: {yaml_path}")
84
+
85
+ with open(yaml_file, encoding="utf-8") as f:
86
+ yaml_text = f.read()
87
+
88
+ return load_connections_from_text(yaml_text, yaml_file)
@@ -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",
@@ -14,13 +14,13 @@ import re
14
14
  import sys
15
15
  from datetime import datetime, timezone
16
16
  from pathlib import Path
17
- from typing import Callable, Dict, List, Optional
17
+ from typing import Callable, Dict, List, Optional, TypeAlias
18
18
  from uuid import uuid4
19
19
 
20
20
  from mcp.server.fastmcp import FastMCP
21
21
 
22
22
  from . import __version__
23
- from .config import dbeaver_import, load_connections
23
+ from .config import Connection, dbeaver_import, load_connections_from_text
24
24
  from .config.connection import (
25
25
  DEFAULT_CONNECTION_TIMEOUT,
26
26
  DEFAULT_QUERY_TIMEOUT,
@@ -42,6 +42,7 @@ logging.basicConfig(level=logging.INFO)
42
42
  logger = logging.getLogger(__name__)
43
43
  RESULT_FILE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
44
44
  RESULT_DIR_HASH_BYTES = 6
45
+ ConfigMarker: TypeAlias = tuple[int, int] | None
45
46
  SAMPLE_CONNECTIONS_YAML = (
46
47
  files("mcp_read_only_sql")
47
48
  .joinpath("connections.yaml.sample")
@@ -81,6 +82,7 @@ class ReadOnlySQLServer:
81
82
  def __init__(self, runtime_paths: RuntimePaths):
82
83
  self.runtime_paths = runtime_paths
83
84
  self.connections: Dict[str, BaseConnector] = {}
85
+ self._connections_config_marker: ConfigMarker = None
84
86
 
85
87
  self.runtime_paths.ensure_directories()
86
88
  self.mcp = FastMCP("mcp-read-only-sql")
@@ -120,53 +122,103 @@ class ReadOnlySQLServer:
120
122
  raise FileExistsError("Could not allocate a unique managed result file")
121
123
 
122
124
  def _load_connections(self) -> None:
125
+ """Load connections during startup and remember the current config marker."""
123
126
  try:
124
- connections_config = load_connections(self.runtime_paths.connections_file)
125
-
126
- errors = []
127
-
128
- for conn_name, connection in connections_config.items():
129
- try:
130
- if connection.db_type == "postgresql":
131
- if connection.implementation == "cli":
132
- self.connections[conn_name] = PostgreSQLCLIConnector(
133
- connection
134
- )
135
- else:
136
- self.connections[conn_name] = PostgreSQLPythonConnector(
137
- connection
138
- )
139
- elif connection.db_type == "clickhouse":
140
- if connection.implementation == "cli":
141
- self.connections[conn_name] = ClickHouseCLIConnector(
142
- connection
143
- )
144
- else:
145
- self.connections[conn_name] = ClickHousePythonConnector(
146
- connection
147
- )
148
-
149
- logger.info(
150
- "Loaded connection: %s (%s, %s)",
151
- conn_name,
152
- connection.db_type,
153
- connection.implementation,
154
- )
155
-
156
- except ImportError as exc:
157
- errors.append(f" - {conn_name}: Missing dependency - {exc}")
158
- except Exception as exc:
159
- errors.append(f" - {conn_name}: {exc}")
160
-
161
- if errors:
162
- error_msg = "Failed to load some connections:\n" + "\n".join(errors)
163
- logger.error(error_msg)
164
- raise RuntimeError(error_msg)
165
-
127
+ self.connections, self._connections_config_marker = self._build_connections()
166
128
  except Exception as exc:
167
129
  logger.error("Failed to load connections: %s", exc)
168
130
  raise
169
131
 
132
+ def _read_connections_config_marker(self) -> ConfigMarker:
133
+ """Return a lightweight marker for the current connections.yaml state."""
134
+ try:
135
+ stat_result = self.runtime_paths.connections_file.stat()
136
+ except FileNotFoundError:
137
+ return None
138
+ return (stat_result.st_mtime_ns, stat_result.st_size)
139
+
140
+ def _build_connector(self, connection: Connection) -> BaseConnector:
141
+ """Create the correct connector implementation for a validated connection."""
142
+ if connection.db_type == "postgresql":
143
+ if connection.implementation == "cli":
144
+ return PostgreSQLCLIConnector(connection)
145
+ return PostgreSQLPythonConnector(connection)
146
+ if connection.db_type == "clickhouse":
147
+ if connection.implementation == "cli":
148
+ return ClickHouseCLIConnector(connection)
149
+ return ClickHousePythonConnector(connection)
150
+ raise ValueError(f"Unsupported database type: {connection.db_type}")
151
+
152
+ def _read_connections_config_snapshot(self) -> tuple[str, ConfigMarker]:
153
+ """Read connections.yaml once and return content with its matching marker."""
154
+ yaml_file = self.runtime_paths.connections_file.expanduser()
155
+ try:
156
+ with open(yaml_file, encoding="utf-8") as f:
157
+ yaml_text = f.read()
158
+ stat_result = os.fstat(f.fileno())
159
+ except FileNotFoundError as exc:
160
+ raise FileNotFoundError(
161
+ f"Configuration file not found: {self.runtime_paths.connections_file}"
162
+ ) from exc
163
+ return yaml_text, (stat_result.st_mtime_ns, stat_result.st_size)
164
+
165
+ def _build_connections(self) -> tuple[Dict[str, BaseConnector], ConfigMarker]:
166
+ """Build a fresh connector map from one config snapshot without mutating state."""
167
+ yaml_text, marker = self._read_connections_config_snapshot()
168
+ connections_config = load_connections_from_text(
169
+ yaml_text, self.runtime_paths.connections_file
170
+ )
171
+ built_connections: Dict[str, BaseConnector] = {}
172
+ errors = []
173
+
174
+ for conn_name, connection in connections_config.items():
175
+ try:
176
+ built_connections[conn_name] = self._build_connector(connection)
177
+ logger.info(
178
+ "Loaded connection: %s (%s, %s)",
179
+ conn_name,
180
+ connection.db_type,
181
+ connection.implementation,
182
+ )
183
+ except ImportError as exc:
184
+ errors.append(f" - {conn_name}: Missing dependency - {exc}")
185
+ except Exception as exc:
186
+ errors.append(f" - {conn_name}: {exc}")
187
+
188
+ if errors:
189
+ error_msg = "Failed to load some connections:\n" + "\n".join(errors)
190
+ logger.error(error_msg)
191
+ raise RuntimeError(error_msg)
192
+
193
+ return built_connections, marker
194
+
195
+ def _reload_connections_if_needed(self) -> None:
196
+ """Reload connections.yaml if it changed, preserving the last good config on errors."""
197
+ previous_marker = self._connections_config_marker
198
+ current_marker = self._read_connections_config_marker()
199
+ if current_marker == previous_marker:
200
+ return
201
+
202
+ logger.info("Detected change in %s; reloading connections", self.runtime_paths.connections_file)
203
+ try:
204
+ new_connections, new_marker = self._build_connections()
205
+ except Exception as exc:
206
+ logger.warning(
207
+ "Failed to reload connections from %s; keeping %s previously loaded connection(s): %s",
208
+ self.runtime_paths.connections_file,
209
+ len(self.connections),
210
+ exc,
211
+ )
212
+ return
213
+
214
+ self.connections = new_connections
215
+ self._connections_config_marker = new_marker
216
+ logger.info(
217
+ "Reloaded %s connection(s) from %s",
218
+ len(self.connections),
219
+ self.runtime_paths.connections_file,
220
+ )
221
+
170
222
  def _setup_tools(self) -> None:
171
223
  """Setup MCP tools using FastMCP decorators."""
172
224
 
@@ -192,6 +244,7 @@ class ReadOnlySQLServer:
192
244
  directory for this server instance. Successful query results
193
245
  are retained there until removed by the operator.
194
246
  """
247
+ self._reload_connections_if_needed()
195
248
  if connection_name not in self.connections:
196
249
  raise ValueError(
197
250
  f"Connection '{connection_name}' not found. Available connections: {', '.join(self.connections.keys())}"
@@ -223,6 +276,7 @@ class ReadOnlySQLServer:
223
276
  hosts for each connection, while ``database`` and ``databases``
224
277
  describe the default database and allowed database list.
225
278
  """
279
+ self._reload_connections_if_needed()
226
280
  conn_list = []
227
281
 
228
282
  for conn_name, connector in self.connections.items():