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.
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/CHANGELOG.md +18 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/PKG-INFO +4 -1
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/README.md +2 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/pyproject.toml +9 -1
- mcp_read_only_sql-0.2.6/src/mcp_read_only_sql/cli_binaries.py +158 -0
- mcp_read_only_sql-0.2.6/src/mcp_read_only_sql/config/__init__.py +14 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/loader.py +44 -22
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/server.py +98 -44
- mcp_read_only_sql-0.2.6/tests/test_cli_binaries.py +93 -0
- mcp_read_only_sql-0.2.6/tests/test_connections_reload.py +350 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_run_query_file_output.py +1 -0
- mcp_read_only_sql-0.2.2/src/mcp_read_only_sql/config/__init__.py +0 -8
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.github/workflows/publish.yml +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.github/workflows/test.yml +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.gitignore +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/.mcp.json +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/AGENTS.md +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/LICENSE +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/RELEASING.md +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/conftest.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/clickhouse/init/01_init.sql +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/postgres/init/01_schema.sql +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/postgres/init/02_data.sql +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker/ssh/Dockerfile +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/docker-compose.yml +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/justfile +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/pytest.ini +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/run_tests.sh +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/__init__.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/connection.py +0 -0
- {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
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/config/parser.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/connectors/base.py +0 -0
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/tools/__init__.py +0 -0
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/src/mcp_read_only_sql/utils/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/KNOWN_ISSUES.md +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/README.md +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/__init__.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/conftest.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/conftest_new.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/connections-test.yaml +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/pytest_plugins.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/sql_statement_lists.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_ssh_tunnels.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_system_ssh.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_cli_versions.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_clickhouse_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_config_connection.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_config_parser.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_connection_utils.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_connector_implementations.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_dbeaver_import.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_docker_connectivity.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_error_handling.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_host_mapping_flow.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_limits.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_mcp_protocol.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_mcp_server.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_postgresql_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_process_cleanup.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_result_serialization.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_layers.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_security_readonly_integration.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_serialization_fallback.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_server.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_server_selection.py +0 -0
- {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.6}/tests/test_ssh_timeout.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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: {
|
|
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)
|
{mcp_read_only_sql-0.2.2 → 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",
|
|
@@ -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,
|
|
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
|
-
|
|
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():
|