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