mcp-read-only-sql 0.2.2__tar.gz → 0.2.5__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 (92) hide show
  1. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/CHANGELOG.md +12 -0
  2. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/PKG-INFO +2 -1
  3. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/pyproject.toml +9 -1
  4. mcp_read_only_sql-0.2.5/src/mcp_read_only_sql/config/__init__.py +14 -0
  5. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/loader.py +44 -22
  6. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/server.py +98 -44
  7. mcp_read_only_sql-0.2.5/tests/test_connections_reload.py +350 -0
  8. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_run_query_file_output.py +1 -0
  9. mcp_read_only_sql-0.2.2/src/mcp_read_only_sql/config/__init__.py +0 -8
  10. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/.github/workflows/publish.yml +0 -0
  11. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/.github/workflows/test.yml +0 -0
  12. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/.gitignore +0 -0
  13. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/.mcp.json +0 -0
  14. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/AGENTS.md +0 -0
  15. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/LICENSE +0 -0
  16. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/README.md +0 -0
  17. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
  18. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/RELEASING.md +0 -0
  19. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/conftest.py +0 -0
  20. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/connections.yaml.sample +0 -0
  21. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/docker/clickhouse/init/01_init.sql +0 -0
  22. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/docker/postgres/init/01_schema.sql +0 -0
  23. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/docker/postgres/init/02_data.sql +0 -0
  24. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/docker/ssh/Dockerfile +0 -0
  25. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/docker-compose.yml +0 -0
  26. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/justfile +0 -0
  27. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/pytest.ini +0 -0
  28. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/run_tests.sh +0 -0
  29. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/__init__.py +0 -0
  30. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/connection.py +0 -0
  31. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/dbeaver_import.py +0 -0
  32. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/parser.py +0 -0
  33. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
  34. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
  35. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/base.py +0 -0
  36. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/base_cli.py +0 -0
  37. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
  38. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +0 -0
  39. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/python.py +0 -0
  40. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
  41. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/cli.py +0 -0
  42. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/python.py +0 -0
  43. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/runtime_paths.py +0 -0
  44. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/__init__.py +0 -0
  45. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
  46. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py +0 -0
  47. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
  48. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/__init__.py +0 -0
  49. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
  50. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/json_serializer.py +0 -0
  51. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
  52. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/ssh_tunnel.py +0 -0
  53. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py +0 -0
  54. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/timeout_wrapper.py +0 -0
  55. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
  56. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/KNOWN_ISSUES.md +0 -0
  57. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/README.md +0 -0
  58. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/__init__.py +0 -0
  59. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/conftest.py +0 -0
  60. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/conftest_new.py +0 -0
  61. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/connections-test.yaml +0 -0
  62. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/docker_test_config.py +0 -0
  63. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/pytest_plugins.py +0 -0
  64. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/sql_statement_lists.py +0 -0
  65. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_cli_ssh_tunnels.py +0 -0
  66. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_cli_system_ssh.py +0 -0
  67. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_cli_versions.py +0 -0
  68. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_clickhouse_cli_fallback.py +0 -0
  69. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_config_connection.py +0 -0
  70. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_config_parser.py +0 -0
  71. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_connection_utils.py +0 -0
  72. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_connector_implementations.py +0 -0
  73. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_dbeaver_import.py +0 -0
  74. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_docker_connectivity.py +0 -0
  75. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_docker_test_config.py +0 -0
  76. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_error_handling.py +0 -0
  77. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_host_mapping_flow.py +0 -0
  78. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_limits.py +0 -0
  79. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_mcp_protocol.py +0 -0
  80. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_mcp_server.py +0 -0
  81. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_postgresql_cli_fallback.py +0 -0
  82. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_process_cleanup.py +0 -0
  83. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_result_serialization.py +0 -0
  84. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_runtime_paths.py +0 -0
  85. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_security_layers.py +0 -0
  86. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly.py +0 -0
  87. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly_integration.py +0 -0
  88. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_serialization_fallback.py +0 -0
  89. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_server.py +0 -0
  90. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_server_selection.py +0 -0
  91. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_ssh_timeout.py +0 -0
  92. {mcp_read_only_sql-0.2.2 → mcp_read_only_sql-0.2.5}/tests/test_ssh_tunnels.py +0 -0
@@ -7,6 +7,18 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.5] - 2026-04-21
11
+
12
+ ### Added
13
+
14
+ - Added hot-reload regression tests covering connection add/change/remove flows, invalid live edits, and config changes that happen during a reload attempt.
15
+
16
+ ### Fixed
17
+
18
+ - Reloaded `connections.yaml` automatically before both `list_connections` and `run_query_read_only`, without requiring an MCP server restart.
19
+ - 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.
20
+ - 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.
21
+
10
22
  ## [0.2.2] - 2026-04-03
11
23
 
12
24
  ### 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.5
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-read-only-sql"
3
- version = "0.2.2"
3
+ version = "0.2.5"
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,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)
@@ -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():
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for runtime reloading of connections.yaml."""
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ import yaml
9
+ from mcp.server.fastmcp.exceptions import ToolError
10
+
11
+ import mcp_read_only_sql.server as server_module
12
+ from mcp_read_only_sql.config import Connection
13
+ from mcp_read_only_sql.connectors.base import BaseConnector
14
+ from mcp_read_only_sql.runtime_paths import RuntimePaths
15
+ from mcp_read_only_sql.server import ReadOnlySQLServer
16
+
17
+
18
+ class ReloadTestConnector(BaseConnector):
19
+ """Connector stub that surfaces the currently loaded config in TSV output."""
20
+
21
+ async def execute_query(self, query: str, database=None, server=None) -> str: # type: ignore[override]
22
+ selected_server = self._select_server(server)
23
+ selected_database = self._resolve_database(database)
24
+ return (
25
+ "connection\tserver\tdatabase\tuser\n"
26
+ f"{self.name}\t{selected_server.host}\t{selected_database}\t{self.username}"
27
+ )
28
+
29
+
30
+ def make_runtime_paths(tmp_path: Path) -> RuntimePaths:
31
+ """Create isolated runtime paths for reload tests."""
32
+ runtime_paths = RuntimePaths(
33
+ config_dir=tmp_path / "config",
34
+ state_dir=tmp_path / "state",
35
+ cache_dir=tmp_path / "cache",
36
+ )
37
+ runtime_paths.ensure_directories()
38
+ return runtime_paths
39
+
40
+
41
+ def write_connections_file(path: Path, connections: list[dict[str, object]]) -> None:
42
+ """Write a YAML connections file for a reload scenario."""
43
+ path.write_text(
44
+ yaml.safe_dump(connections, sort_keys=False),
45
+ encoding="utf-8",
46
+ )
47
+
48
+
49
+ def parse_tsv_rows(tsv_text: str) -> list[dict[str, str]]:
50
+ """Parse the server's tab-separated list_connections response."""
51
+ lines = tsv_text.splitlines()
52
+ headers = lines[0].split("\t")
53
+ rows: list[dict[str, str]] = []
54
+ for line in lines[1:]:
55
+ if not line:
56
+ continue
57
+ rows.append(dict(zip(headers, line.split("\t"))))
58
+ return rows
59
+
60
+
61
+ def apply_stub_connectors(monkeypatch: pytest.MonkeyPatch) -> None:
62
+ """Route server connector construction to the in-memory reload test stub."""
63
+ monkeypatch.setattr(
64
+ server_module, "PostgreSQLCLIConnector", ReloadTestConnector
65
+ )
66
+ monkeypatch.setattr(
67
+ server_module, "PostgreSQLPythonConnector", ReloadTestConnector
68
+ )
69
+ monkeypatch.setattr(
70
+ server_module, "ClickHouseCLIConnector", ReloadTestConnector
71
+ )
72
+ monkeypatch.setattr(
73
+ server_module, "ClickHousePythonConnector", ReloadTestConnector
74
+ )
75
+
76
+
77
+ def count_load_connections(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
78
+ """Wrap load_connections so tests can assert whether reloads happened."""
79
+ real_load_connections = server_module.load_connections_from_text
80
+ call_counter = {"count": 0}
81
+
82
+ def wrapped_load_connections(
83
+ yaml_text: str, source: str | Path = "<memory>"
84
+ ) -> dict[str, Connection]:
85
+ call_counter["count"] += 1
86
+ return real_load_connections(yaml_text, source)
87
+
88
+ monkeypatch.setattr(
89
+ server_module, "load_connections_from_text", wrapped_load_connections
90
+ )
91
+ return call_counter
92
+
93
+
94
+ async def list_connections(server: ReadOnlySQLServer) -> list[dict[str, str]]:
95
+ """Call list_connections directly on the in-process FastMCP server."""
96
+ result = await server.mcp._tool_manager.call_tool(
97
+ "list_connections",
98
+ {},
99
+ convert_result=False,
100
+ )
101
+ assert isinstance(result, str)
102
+ return parse_tsv_rows(result)
103
+
104
+
105
+ async def run_query(server: ReadOnlySQLServer, connection_name: str) -> Path:
106
+ """Run a query against the in-process FastMCP server and return the TSV path."""
107
+ result = await server.mcp._tool_manager.call_tool(
108
+ "run_query_read_only",
109
+ {"connection_name": connection_name, "query": "SELECT 1"},
110
+ convert_result=False,
111
+ )
112
+ assert isinstance(result, str)
113
+ return Path(result)
114
+
115
+
116
+ @pytest.mark.anyio
117
+ async def test_tools_reload_connections_after_config_changes(tmp_path, monkeypatch):
118
+ apply_stub_connectors(monkeypatch)
119
+ runtime_paths = make_runtime_paths(tmp_path)
120
+ write_connections_file(
121
+ runtime_paths.connections_file,
122
+ [
123
+ {
124
+ "connection_name": "alpha",
125
+ "type": "postgresql",
126
+ "implementation": "cli",
127
+ "servers": ["alpha-db:5432"],
128
+ "db": "analytics",
129
+ "username": "alpha_user",
130
+ "password": "secret",
131
+ },
132
+ {
133
+ "connection_name": "beta",
134
+ "type": "postgresql",
135
+ "implementation": "cli",
136
+ "servers": ["beta-db:5432"],
137
+ "db": "warehouse",
138
+ "username": "beta_user",
139
+ "password": "secret",
140
+ },
141
+ ],
142
+ )
143
+ server = ReadOnlySQLServer(runtime_paths)
144
+
145
+ initial_connections = await list_connections(server)
146
+ assert [row["name"] for row in initial_connections] == ["alpha", "beta"]
147
+
148
+ write_connections_file(
149
+ runtime_paths.connections_file,
150
+ [
151
+ {
152
+ "connection_name": "beta",
153
+ "type": "postgresql",
154
+ "implementation": "cli",
155
+ "servers": ["beta-db-v2:5432"],
156
+ "db": "warehouse",
157
+ "username": "beta_user_v2",
158
+ "password": "secret",
159
+ },
160
+ {
161
+ "connection_name": "gamma",
162
+ "type": "postgresql",
163
+ "implementation": "cli",
164
+ "servers": ["gamma-db:5432"],
165
+ "db": "warehouse",
166
+ "username": "gamma_user",
167
+ "password": "secret",
168
+ },
169
+ ],
170
+ )
171
+
172
+ reloaded_connections = await list_connections(server)
173
+ assert [row["name"] for row in reloaded_connections] == ["beta", "gamma"]
174
+ reloaded_map = {row["name"]: row for row in reloaded_connections}
175
+ assert reloaded_map["beta"]["servers"] == "beta-db-v2"
176
+ assert reloaded_map["beta"]["user"] == "beta_user_v2"
177
+
178
+ with pytest.raises(ToolError, match="Connection 'alpha' not found"):
179
+ await run_query(server, "alpha")
180
+
181
+ gamma_result = await run_query(server, "gamma")
182
+ assert gamma_result.read_text(encoding="utf-8").splitlines() == [
183
+ "connection\tserver\tdatabase\tuser",
184
+ "gamma\tgamma-db\twarehouse\tgamma_user",
185
+ ]
186
+
187
+
188
+ @pytest.mark.anyio
189
+ async def test_reload_skips_unchanged_config(tmp_path, monkeypatch):
190
+ apply_stub_connectors(monkeypatch)
191
+ load_calls = count_load_connections(monkeypatch)
192
+ runtime_paths = make_runtime_paths(tmp_path)
193
+ write_connections_file(
194
+ runtime_paths.connections_file,
195
+ [
196
+ {
197
+ "connection_name": "alpha",
198
+ "type": "postgresql",
199
+ "implementation": "cli",
200
+ "servers": ["alpha-db:5432"],
201
+ "db": "analytics",
202
+ "username": "alpha_user",
203
+ "password": "secret",
204
+ }
205
+ ],
206
+ )
207
+ server = ReadOnlySQLServer(runtime_paths)
208
+
209
+ assert load_calls["count"] == 1
210
+
211
+ await list_connections(server)
212
+ await list_connections(server)
213
+ await run_query(server, "alpha")
214
+
215
+ assert load_calls["count"] == 1
216
+
217
+
218
+ @pytest.mark.anyio
219
+ async def test_invalid_reload_keeps_last_good_connections(
220
+ tmp_path, monkeypatch, caplog
221
+ ):
222
+ apply_stub_connectors(monkeypatch)
223
+ load_calls = count_load_connections(monkeypatch)
224
+ runtime_paths = make_runtime_paths(tmp_path)
225
+ write_connections_file(
226
+ runtime_paths.connections_file,
227
+ [
228
+ {
229
+ "connection_name": "alpha",
230
+ "type": "postgresql",
231
+ "implementation": "cli",
232
+ "servers": ["alpha-db:5432"],
233
+ "db": "analytics",
234
+ "username": "alpha_user",
235
+ "password": "secret",
236
+ }
237
+ ],
238
+ )
239
+ server = ReadOnlySQLServer(runtime_paths)
240
+ caplog.set_level(logging.WARNING)
241
+
242
+ runtime_paths.connections_file.write_text("invalid: true\n", encoding="utf-8")
243
+
244
+ preserved_connections = await list_connections(server)
245
+ assert [row["name"] for row in preserved_connections] == ["alpha"]
246
+
247
+ alpha_result = await run_query(server, "alpha")
248
+ assert alpha_result.read_text(encoding="utf-8").splitlines() == [
249
+ "connection\tserver\tdatabase\tuser",
250
+ "alpha\talpha-db\tanalytics\talpha_user",
251
+ ]
252
+
253
+ await list_connections(server)
254
+
255
+ assert load_calls["count"] == 4
256
+ assert "keeping 1 previously loaded connection(s)" in caplog.text
257
+
258
+
259
+ @pytest.mark.anyio
260
+ async def test_reload_retries_after_file_changes_during_reload(tmp_path, monkeypatch):
261
+ apply_stub_connectors(monkeypatch)
262
+ runtime_paths = make_runtime_paths(tmp_path)
263
+ write_connections_file(
264
+ runtime_paths.connections_file,
265
+ [
266
+ {
267
+ "connection_name": "alpha",
268
+ "type": "postgresql",
269
+ "implementation": "cli",
270
+ "servers": ["alpha-db:5432"],
271
+ "db": "analytics",
272
+ "username": "alpha_user",
273
+ "password": "secret",
274
+ }
275
+ ],
276
+ )
277
+ server = ReadOnlySQLServer(runtime_paths)
278
+
279
+ beta_connections = [
280
+ {
281
+ "connection_name": "beta",
282
+ "type": "postgresql",
283
+ "implementation": "cli",
284
+ "servers": ["beta-db:5432"],
285
+ "db": "warehouse",
286
+ "username": "beta_user",
287
+ "password": "secret",
288
+ }
289
+ ]
290
+ gamma_connections = [
291
+ {
292
+ "connection_name": "gamma",
293
+ "type": "postgresql",
294
+ "implementation": "cli",
295
+ "servers": ["gamma-db:5432"],
296
+ "db": "warehouse",
297
+ "username": "gamma_user",
298
+ "password": "secret",
299
+ }
300
+ ]
301
+ write_connections_file(runtime_paths.connections_file, beta_connections)
302
+
303
+ original_build_connector = server._build_connector
304
+ triggered_reload_edit = False
305
+
306
+ def build_connector_and_mutate_file(connection: Connection) -> BaseConnector:
307
+ nonlocal triggered_reload_edit
308
+ connector = original_build_connector(connection)
309
+ if not triggered_reload_edit and connection.name == "beta":
310
+ write_connections_file(runtime_paths.connections_file, gamma_connections)
311
+ triggered_reload_edit = True
312
+ return connector
313
+
314
+ monkeypatch.setattr(server, "_build_connector", build_connector_and_mutate_file)
315
+
316
+ beta_result = await list_connections(server)
317
+ assert [row["name"] for row in beta_result] == ["beta"]
318
+
319
+ gamma_result = await list_connections(server)
320
+ assert [row["name"] for row in gamma_result] == ["gamma"]
321
+
322
+
323
+ @pytest.mark.anyio
324
+ async def test_missing_connections_file_keeps_last_good_config(
325
+ tmp_path, monkeypatch, caplog
326
+ ):
327
+ apply_stub_connectors(monkeypatch)
328
+ runtime_paths = make_runtime_paths(tmp_path)
329
+ write_connections_file(
330
+ runtime_paths.connections_file,
331
+ [
332
+ {
333
+ "connection_name": "alpha",
334
+ "type": "postgresql",
335
+ "implementation": "cli",
336
+ "servers": ["alpha-db:5432"],
337
+ "db": "analytics",
338
+ "username": "alpha_user",
339
+ "password": "secret",
340
+ }
341
+ ],
342
+ )
343
+ server = ReadOnlySQLServer(runtime_paths)
344
+ caplog.set_level(logging.WARNING)
345
+
346
+ runtime_paths.connections_file.unlink()
347
+
348
+ preserved_connections = await list_connections(server)
349
+ assert [row["name"] for row in preserved_connections] == ["alpha"]
350
+ assert "Failed to reload connections" in caplog.text
@@ -30,6 +30,7 @@ def build_stub_server(
30
30
  server = ReadOnlySQLServer.__new__(ReadOnlySQLServer)
31
31
  server.runtime_paths = runtime_paths
32
32
  server.connections = {connector.name: connector}
33
+ server._connections_config_marker = None
33
34
  server.mcp = FastMCP("mcp-read-only-sql-test")
34
35
  server._setup_tools()
35
36
  return server
@@ -1,8 +0,0 @@
1
- """
2
- Configuration module for connection management
3
- """
4
-
5
- from .connection import Connection, Server, SSHTunnelConfig
6
- from .loader import load_connections
7
-
8
- __all__ = ["Connection", "Server", "SSHTunnelConfig", "load_connections"]