mcp-read-only-sql 0.2.1__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 (93) hide show
  1. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/AGENTS.md +5 -3
  2. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/CHANGELOG.md +28 -0
  3. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/PKG-INFO +3 -1
  4. mcp_read_only_sql-0.2.5/RELEASING.md +76 -0
  5. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/pyproject.toml +13 -1
  6. mcp_read_only_sql-0.2.5/src/mcp_read_only_sql/config/__init__.py +14 -0
  7. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/connection.py +1 -1
  8. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/dbeaver_import.py +54 -31
  9. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/loader.py +51 -27
  10. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +74 -48
  11. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/python.py +3 -3
  12. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/cli.py +52 -36
  13. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/python.py +6 -3
  14. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/server.py +98 -44
  15. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py +2 -0
  16. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/json_serializer.py +3 -3
  17. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/ssh_tunnel.py +5 -2
  18. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/timeout_wrapper.py +9 -8
  19. mcp_read_only_sql-0.2.5/tests/test_connections_reload.py +350 -0
  20. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_run_query_file_output.py +1 -0
  21. mcp_read_only_sql-0.2.1/RELEASING.md +0 -109
  22. mcp_read_only_sql-0.2.1/src/mcp_read_only_sql/config/__init__.py +0 -8
  23. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.github/workflows/publish.yml +0 -0
  24. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.github/workflows/test.yml +0 -0
  25. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.gitignore +0 -0
  26. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.mcp.json +0 -0
  27. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/LICENSE +0 -0
  28. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/README.md +0 -0
  29. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
  30. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/conftest.py +0 -0
  31. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/connections.yaml.sample +0 -0
  32. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/clickhouse/init/01_init.sql +0 -0
  33. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/postgres/init/01_schema.sql +0 -0
  34. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/postgres/init/02_data.sql +0 -0
  35. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/ssh/Dockerfile +0 -0
  36. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker-compose.yml +0 -0
  37. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/justfile +0 -0
  38. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/pytest.ini +0 -0
  39. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/run_tests.sh +0 -0
  40. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/__init__.py +0 -0
  41. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/parser.py +0 -0
  42. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
  43. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
  44. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/base.py +0 -0
  45. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/base_cli.py +0 -0
  46. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
  47. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
  48. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/runtime_paths.py +0 -0
  49. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/__init__.py +0 -0
  50. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
  51. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
  52. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/__init__.py +0 -0
  53. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
  54. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
  55. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py +0 -0
  56. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
  57. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/KNOWN_ISSUES.md +0 -0
  58. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/README.md +0 -0
  59. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/__init__.py +0 -0
  60. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/conftest.py +0 -0
  61. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/conftest_new.py +0 -0
  62. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/connections-test.yaml +0 -0
  63. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/docker_test_config.py +0 -0
  64. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/pytest_plugins.py +0 -0
  65. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/sql_statement_lists.py +0 -0
  66. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_ssh_tunnels.py +0 -0
  67. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_system_ssh.py +0 -0
  68. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_versions.py +0 -0
  69. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_clickhouse_cli_fallback.py +0 -0
  70. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_config_connection.py +0 -0
  71. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_config_parser.py +0 -0
  72. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_connection_utils.py +0 -0
  73. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_connector_implementations.py +0 -0
  74. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_dbeaver_import.py +0 -0
  75. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_docker_connectivity.py +0 -0
  76. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_docker_test_config.py +0 -0
  77. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_error_handling.py +0 -0
  78. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_host_mapping_flow.py +0 -0
  79. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_limits.py +0 -0
  80. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_mcp_protocol.py +0 -0
  81. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_mcp_server.py +0 -0
  82. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_postgresql_cli_fallback.py +0 -0
  83. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_process_cleanup.py +0 -0
  84. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_result_serialization.py +0 -0
  85. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_runtime_paths.py +0 -0
  86. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_layers.py +0 -0
  87. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly.py +0 -0
  88. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly_integration.py +0 -0
  89. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_serialization_fallback.py +0 -0
  90. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_server.py +0 -0
  91. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_server_selection.py +0 -0
  92. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_ssh_timeout.py +0 -0
  93. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_ssh_tunnels.py +0 -0
@@ -1,7 +1,7 @@
1
1
  # Repository Guidelines
2
2
 
3
3
  ## Project Structure & Module Organization
4
- `src/mcp_read_only_sql/server.py` exposes the MCP entry point, while `src/mcp_read_only_sql/connectors/` hosts the PostgreSQL and ClickHouse adapters. Cross-cutting helpers (`ssh_tunnel.py`, `sql_guard.py`, TSV formatting) live in `src/mcp_read_only_sql/utils/`. Configuration tooling, including DBeaver import and manifest validation, sits in `src/mcp_read_only_sql/config/` and `src/mcp_read_only_sql/tools/`. Tests are grouped in `tests/`, and Docker fixtures for integration runs reside in `docker/`. The package ships a sample `connections.yaml`, and runtime config lives under `~/.config/lukleh/mcp-read-only-sql/`.
4
+ `src/mcp_read_only_sql/server.py` exposes the MCP entry point, while `src/mcp_read_only_sql/connectors/` hosts the PostgreSQL and ClickHouse adapters for both CLI and Python implementations. Cross-cutting helpers (`ssh_tunnel.py`, `ssh_tunnel_cli.py`, `sql_guard.py`, TSV formatting, timeout handling) live in `src/mcp_read_only_sql/utils/`. Configuration tooling, including DBeaver import and manifest validation, sits in `src/mcp_read_only_sql/config/` and `src/mcp_read_only_sql/tools/`. Tests are grouped in `tests/`, and Docker fixtures for integration runs reside in `docker/`. The package ships a sample `connections.yaml`, and runtime config lives under `~/.config/lukleh/mcp-read-only-sql/`.
5
5
 
6
6
  ## Build, Test, and Development Commands
7
7
  - `uv sync --extra dev` — install runtime and development dependencies.
@@ -11,9 +11,11 @@
11
11
  - `just validate` — lint `connections.yaml` against the schema and safety checks.
12
12
  - `just test` — spin up Dockerized fixtures and execute the full pytest suite via `./run_tests.sh`.
13
13
  - `uv run python -m pytest tests/test_sql_guard.py` — run an individual module when iterating quickly.
14
+ - `uv run ruff check .` and `uv run black .` — run linting and formatting for the Python tree.
15
+ - `uv run ty check` — type-check the full `src/` tree; there are no remaining package excludes.
14
16
 
15
17
  ## Coding Style & Naming Conventions
16
- Target Python 3.11+, four-space indentation, and Unix newlines. Format with `uv run black .` and lint via `uv run ruff check .`; CI mirrors these checks. Modules and callables use snake_case, classes PascalCase (e.g., `TestReadOnlyGuards`), and immutable settings uppercase (`DEFAULT_QUERY_TIMEOUT`). Apply type hints on public surfaces and keep docstrings brief, emphasising read-only guarantees.
18
+ Target Python 3.11+, four-space indentation, and Unix newlines. Format with `uv run black .`, lint via `uv run ruff check .`, and keep `uv run ty check` passing for `src/`. Modules and callables use snake_case, classes PascalCase (e.g., `TestReadOnlyGuards`), and immutable settings uppercase (`DEFAULT_QUERY_TIMEOUT`). Apply type hints on public surfaces and keep docstrings brief, emphasizing read-only guarantees and connector behavior.
17
19
 
18
20
  ## Testing Guidelines
19
21
  Pytest discovers `test_*.py` modules, `Test*` classes, and `test_*` functions per `pytest.ini`. Use the built-in markers (`security`, `cli`, `python`, `ssh`, `slow`) to scope runs, e.g. `pytest -m "security and not slow"`. A 30s default timeout applies, so tear down tunnels and subprocesses explicitly. `just test` emits JUnit XML at `test-results/pytest.xml` for CI uploads.
@@ -22,4 +24,4 @@ Pytest discovers `test_*.py` modules, `Test*` classes, and `test_*` functions pe
22
24
  Recent commits favour imperative, concise subjects (“Refactor PostgreSQL read-only guard into shared utility”). Keep changes focused and explain security or connector impacts in the body when needed. Pull requests should describe motivation, list affected modules or scripts, link issues, and attach logs or screenshots for operational updates.
23
25
 
24
26
  ## Security & Configuration Tips
25
- Do not relax safeguards in `src/mcp_read_only_sql/utils/sql_guard.py` or connector factories without matching tests. Treat `connections.yaml` as sensitive; the server stores database and SSH passwords directly in that file, so keep it private and user-readable only. Use the SSH tunnel helpers instead of bespoke subprocesses to preserve timeout and read-only enforcement.
27
+ Do not relax safeguards in `src/mcp_read_only_sql/utils/sql_guard.py` or connector factories without matching tests. Treat `connections.yaml` as sensitive; the server stores database and SSH passwords directly in that file, so keep it private and user-readable only. Use the shared SSH tunnel and timeout helpers instead of bespoke subprocesses so read-only enforcement, cleanup, and managed result-file behavior stay consistent.
@@ -7,6 +7,34 @@ 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
+
22
+ ## [0.2.2] - 2026-04-03
23
+
24
+ ### Added
25
+
26
+ - Added `ty` as a supported development check for the full packaged `src/` tree.
27
+
28
+ ### Changed
29
+
30
+ - Added repo-specific `AGENTS.md` guidance covering connector layout, shared timeout and SSH helpers, and the typed development workflow.
31
+ - Reworked `RELEASING.md` into an evergreen release checklist with explicit validation, tagging, and publish steps.
32
+
33
+ ### Fixed
34
+
35
+ - Flushed the final buffered TSV line when PostgreSQL and ClickHouse CLI queries stream results to an output file.
36
+ - Hardened DBeaver credential import so missing or non-dictionary decrypted sections are ignored cleanly instead of being treated as valid connection data.
37
+
10
38
  ## [0.2.1] - 2026-04-02
11
39
 
12
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-read-only-sql
3
- Version: 0.2.1
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
@@ -29,6 +29,8 @@ Requires-Dist: black>=23.0.0; extra == 'dev'
29
29
  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
+ Requires-Dist: ty>=0.0.28; extra == 'dev'
33
+ Requires-Dist: vulture>=2.16; extra == 'dev'
32
34
  Description-Content-Type: text/markdown
33
35
 
34
36
  # MCP Read-Only SQL Server
@@ -0,0 +1,76 @@
1
+ # Releasing `mcp-read-only-sql`
2
+
3
+ This repository publishes to PyPI from Git tags through GitHub Actions.
4
+
5
+ Release automation lives in:
6
+ - `.github/workflows/publish.yml`
7
+ - `.github/workflows/test.yml`
8
+ - the GitHub environment named `pypi`
9
+ - the PyPI trusted publisher for `lukleh/mcp-read-only-sql`
10
+
11
+ ## What To Change For A Release
12
+
13
+ Update these files in the release commit:
14
+
15
+ 1. `CHANGELOG.md`
16
+ Move the user-visible items from `## [Unreleased]` into a new section:
17
+ `## [X.Y.Z] - YYYY-MM-DD`
18
+ 2. `pyproject.toml`
19
+ Update `[project].version` to `X.Y.Z`
20
+
21
+ Do not expect a tracked `uv.lock` change in this repository. `uv.lock` is
22
+ gitignored here, so it is not part of the release diff.
23
+
24
+ `RELEASING.md` should stay evergreen. It should explain the process, not carry a
25
+ release-specific version number.
26
+
27
+ ## How To Update The Version
28
+
29
+ 1. Edit `pyproject.toml`
30
+ 2. Refresh the local environment if needed:
31
+
32
+ ```bash
33
+ uv sync --extra dev
34
+ ```
35
+
36
+ 3. Confirm the installed package metadata and CLI version output match:
37
+
38
+ ```bash
39
+ uv run --extra dev pytest tests/test_server.py tests/test_cli_versions.py -q -k 'package_version_matches_distribution_metadata or support_version'
40
+ ```
41
+
42
+ ## Pre-Release Validation
43
+
44
+ Run the normal local checks before tagging:
45
+
46
+ ```bash
47
+ uv run --extra dev ruff check .
48
+ uv run --extra dev ty check
49
+ ./run_tests.sh
50
+ ```
51
+
52
+ If you are iterating on release metadata only, the focused version tests above
53
+ are the minimum sanity check.
54
+
55
+ ## How To Publish
56
+
57
+ 1. Make the release commit on `main`
58
+ 2. Create and push the matching tag:
59
+
60
+ ```bash
61
+ git tag vX.Y.Z
62
+ git push origin main
63
+ git push origin vX.Y.Z
64
+ ```
65
+
66
+ 3. GitHub Actions starts `.github/workflows/publish.yml`
67
+ 4. The workflow runs the test matrix, builds the wheel and sdist, and smoke-tests the built artifacts
68
+ 5. The final publish job pauses on the GitHub `pypi` environment
69
+ 6. Approve the deployment in GitHub Actions
70
+ 7. GitHub publishes the package to PyPI
71
+
72
+ ## Notes
73
+
74
+ - Keep both `implementation: cli` and `implementation: python` clearly supported in release notes and docs
75
+ - `uvx` installs the Python package only; it does not install `psql`, `clickhouse-client`, or `sshpass`
76
+ - If the public CLI or result-file behavior changes, keep the package smoke tests and README aligned with `mcp-read-only-sql`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-read-only-sql"
3
- version = "0.2.1"
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"
@@ -39,6 +39,8 @@ dev = [
39
39
  "pytest-timeout>=2.2.0",
40
40
  "black>=23.0.0",
41
41
  "ruff>=0.1.0",
42
+ "ty>=0.0.28",
43
+ "vulture>=2.16",
42
44
  ]
43
45
 
44
46
  [build-system]
@@ -50,3 +52,13 @@ mcp-read-only-sql = "mcp_read_only_sql.server:main"
50
52
 
51
53
  [tool.hatch.build.targets.wheel]
52
54
  packages = ["src/mcp_read_only_sql"]
55
+
56
+ [tool.ty.src]
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
+ ]
@@ -109,7 +109,7 @@ class SSHTunnelConfig:
109
109
  raise ValueError("SSH tunnel timeout must be a positive integer")
110
110
 
111
111
  @classmethod
112
- def from_dict(cls, data: Dict[str, Any]) -> "SSHTunnelConfig":
112
+ def from_dict(cls, data: Dict[str, Any]) -> Optional["SSHTunnelConfig"]:
113
113
  """Create SSHTunnelConfig from dict with validation."""
114
114
  if not data.get("enabled", True):
115
115
  return None
@@ -31,10 +31,12 @@ class DBeaverImporter:
31
31
  self.last_requested_names: List[str] = []
32
32
  self.last_seen_names: List[str] = []
33
33
 
34
- def _decrypt_credentials(self) -> Dict[str, Any]:
34
+ def _decrypt_credentials(
35
+ self,
36
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
35
37
  """Decrypt DBeaver credentials file using OpenSSL with default AES key"""
36
38
  if not self.credentials_path.exists():
37
- return {}
39
+ return {}, {}
38
40
 
39
41
  # DBeaver's default AES key and IV
40
42
  key = "babb4a9f774ab853c96c2d653dfe544a"
@@ -60,21 +62,23 @@ class DBeaverImporter:
60
62
  logger.warning(
61
63
  f"Could not decrypt credentials: {result.stderr.decode()}"
62
64
  )
63
- return {}
65
+ return {}, {}
64
66
 
65
67
  # Skip the first 16 bytes (padding) and parse JSON
66
68
  decrypted = result.stdout[16:]
67
69
  credentials_data = json.loads(decrypted)
68
70
 
69
71
  # Extract both connection and SSH credentials
70
- credentials = {}
71
- ssh_credentials = {}
72
+ credentials: dict[str, dict[str, Any]] = {}
73
+ ssh_credentials: dict[str, dict[str, Any]] = {}
72
74
  for conn_id, conn_data in credentials_data.items():
73
75
  if isinstance(conn_data, dict):
74
- if "#connection" in conn_data:
75
- credentials[conn_id] = conn_data["#connection"]
76
- if "network/ssh_tunnel" in conn_data:
77
- ssh_credentials[conn_id] = conn_data["network/ssh_tunnel"]
76
+ db_connection = conn_data.get("#connection")
77
+ if isinstance(db_connection, dict):
78
+ credentials[conn_id] = db_connection
79
+ ssh_connection = conn_data.get("network/ssh_tunnel")
80
+ if isinstance(ssh_connection, dict):
81
+ ssh_credentials[conn_id] = ssh_connection
78
82
 
79
83
  logger.info(
80
84
  f"Successfully decrypted credentials for {len(credentials)} connections"
@@ -83,7 +87,7 @@ class DBeaverImporter:
83
87
 
84
88
  except (subprocess.SubprocessError, json.JSONDecodeError) as e:
85
89
  logger.warning(f"Could not decrypt credentials file: {e}")
86
- return {}
90
+ return {}, {}
87
91
 
88
92
  def import_connections(
89
93
  self, merge_clusters: bool = True, only_names: Optional[List[str]] = None
@@ -98,21 +102,20 @@ class DBeaverImporter:
98
102
  data_sources = json.load(handle)
99
103
 
100
104
  # Try to decrypt credentials
101
- decrypt_result = self._decrypt_credentials()
102
- if isinstance(decrypt_result, tuple):
103
- credentials, ssh_credentials = decrypt_result
104
- else:
105
- credentials = decrypt_result or {}
106
- ssh_credentials = {}
105
+ credentials, ssh_credentials = self._decrypt_credentials()
107
106
 
108
107
  if not credentials and self.credentials_path.exists():
109
108
  # Fallback: try to read as plaintext JSON (some DBeaver versions don't encrypt)
110
109
  try:
111
110
  with self.credentials_path.open("r", encoding="utf-8") as handle:
112
111
  cred_data = json.load(handle)
113
- for conn_id, conn_creds in cred_data.items():
114
- if isinstance(conn_creds, dict) and "#connection" in conn_creds:
115
- credentials[conn_id] = conn_creds["#connection"]
112
+ if isinstance(cred_data, dict):
113
+ for conn_id, conn_creds in cred_data.items():
114
+ if not isinstance(conn_creds, dict):
115
+ continue
116
+ db_connection = conn_creds.get("#connection")
117
+ if isinstance(db_connection, dict):
118
+ credentials[conn_id] = db_connection
116
119
  logger.info("Credentials file was not encrypted")
117
120
  except (json.JSONDecodeError, UnicodeDecodeError):
118
121
  logger.warning(
@@ -125,10 +128,16 @@ class DBeaverImporter:
125
128
  print(f"Filtering: only {len(only_set)} requested connection(s)")
126
129
  self.last_requested_names = requested
127
130
 
128
- connections = []
131
+ connections: list[dict[str, Any]] = []
129
132
  imported_names: List[str] = []
130
133
  seen_names: List[str] = []
131
- for conn_id, conn_data in data_sources.get("connections", {}).items():
134
+ connections_data = data_sources.get("connections", {})
135
+ if not isinstance(connections_data, dict):
136
+ connections_data = {}
137
+
138
+ for conn_id, conn_data in connections_data.items():
139
+ if not isinstance(conn_data, dict):
140
+ continue
132
141
  original_name = conn_data.get("name", conn_id)
133
142
  if only_set and original_name not in only_set:
134
143
  continue
@@ -136,9 +145,7 @@ class DBeaverImporter:
136
145
  seen_names.append(original_name)
137
146
  # Pass both DB and SSH credentials
138
147
  db_creds = credentials.get(conn_id)
139
- ssh_creds = (
140
- ssh_credentials.get(conn_id) if "ssh_credentials" in locals() else None
141
- )
148
+ ssh_creds = ssh_credentials.get(conn_id)
142
149
  converted = self._convert_connection(
143
150
  conn_id, conn_data, db_creds, ssh_creds
144
151
  )
@@ -157,12 +164,14 @@ class DBeaverImporter:
157
164
  self,
158
165
  conn_id: str,
159
166
  conn_data: Dict[str, Any],
160
- creds: Dict[str, Any] = None,
161
- ssh_creds: Dict[str, Any] = None,
162
- ) -> Dict[str, Any]:
167
+ creds: Optional[Dict[str, Any]] = None,
168
+ ssh_creds: Optional[Dict[str, Any]] = None,
169
+ ) -> Optional[Dict[str, Any]]:
163
170
  """Convert a DBeaver connection to our format"""
164
171
  provider = conn_data.get("provider", "")
165
172
  config = conn_data.get("configuration", {})
173
+ if not isinstance(config, dict):
174
+ config = {}
166
175
  conn_name = conn_data.get("name", conn_id)
167
176
 
168
177
  print(f" Processing: {conn_name}...")
@@ -213,8 +222,14 @@ class DBeaverImporter:
213
222
 
214
223
  # Check for SSH tunnel configuration
215
224
  handlers = config.get("handlers", {}) # handlers is inside configuration
225
+ if not isinstance(handlers, dict):
226
+ handlers = {}
216
227
  ssh_handler = handlers.get("ssh_tunnel", {})
228
+ if not isinstance(ssh_handler, dict):
229
+ ssh_handler = {}
217
230
  ssh_props = ssh_handler.get("properties", {}) if ssh_handler else {}
231
+ if not isinstance(ssh_props, dict):
232
+ ssh_props = {}
218
233
 
219
234
  if ssh_handler and ssh_handler.get("enabled"):
220
235
  # Get SSH username from credentials or properties
@@ -368,9 +383,13 @@ class DBeaverImporter:
368
383
  )
369
384
 
370
385
  # Add servers from this connection to the group
386
+ group_servers = group.get("servers")
387
+ if not isinstance(group_servers, list):
388
+ group_servers = []
389
+ group["servers"] = group_servers
371
390
  for server in servers:
372
- if server not in group["servers"]:
373
- group["servers"].append(server)
391
+ if server not in group_servers:
392
+ group_servers.append(server)
374
393
 
375
394
  # Track original DBeaver name
376
395
  if (
@@ -381,8 +400,12 @@ class DBeaverImporter:
381
400
  import_part = (
382
401
  conn["description"].split("(imported from DBeaver:")[-1].rstrip(")")
383
402
  )
384
- if import_part not in group["original_names"]:
385
- group["original_names"].append(import_part)
403
+ group_original_names = group.get("original_names")
404
+ if not isinstance(group_original_names, list):
405
+ group_original_names = []
406
+ group["original_names"] = group_original_names
407
+ if import_part not in group_original_names:
408
+ group_original_names.append(import_part)
386
409
 
387
410
  # Return merged groups in original order with updated descriptions
388
411
  merged = []
@@ -1,50 +1,36 @@
1
1
  """Connection configuration loader."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Dict
4
+ from typing import Any, Dict, cast
5
5
 
6
6
  import yaml
7
7
 
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")
37
21
 
38
- connections = {}
39
- errors = []
22
+ connections: Dict[str, Connection] = {}
23
+ errors: list[str] = []
40
24
 
41
25
  for idx, config in enumerate(raw_configs):
42
26
  if not isinstance(config, dict):
43
27
  errors.append(f"Connection #{idx+1}: must be a dictionary")
44
28
  continue
45
29
 
30
+ config_dict = cast(Dict[str, Any], config)
31
+
46
32
  try:
47
- conn = Connection(config)
33
+ conn = Connection(config_dict)
48
34
 
49
35
  # Check for duplicate names
50
36
  if conn.name in connections:
@@ -54,7 +40,7 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
54
40
 
55
41
  except Exception as e:
56
42
  # Get connection name if available for better error messages
57
- name = config.get("connection_name", f"#{idx+1}")
43
+ name = config_dict.get("connection_name", f"#{idx+1}")
58
44
  errors.append(f"Connection '{name}': {e}")
59
45
 
60
46
  if errors:
@@ -62,3 +48,41 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
62
48
  raise ValueError(error_msg)
63
49
 
64
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)