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.
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/AGENTS.md +5 -3
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/CHANGELOG.md +28 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/PKG-INFO +3 -1
- mcp_read_only_sql-0.2.5/RELEASING.md +76 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/pyproject.toml +13 -1
- mcp_read_only_sql-0.2.5/src/mcp_read_only_sql/config/__init__.py +14 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/connection.py +1 -1
- {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
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/loader.py +51 -27
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/server.py +98 -44
- {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
- {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
- {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
- {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
- mcp_read_only_sql-0.2.5/tests/test_connections_reload.py +350 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_run_query_file_output.py +1 -0
- mcp_read_only_sql-0.2.1/RELEASING.md +0 -109
- mcp_read_only_sql-0.2.1/src/mcp_read_only_sql/config/__init__.py +0 -8
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.github/workflows/publish.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.github/workflows/test.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.gitignore +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/.mcp.json +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/LICENSE +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/README.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/conftest.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/clickhouse/init/01_init.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/postgres/init/01_schema.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/postgres/init/02_data.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker/ssh/Dockerfile +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/docker-compose.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/justfile +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/pytest.ini +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/run_tests.sh +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/parser.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/connectors/base.py +0 -0
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/tools/__init__.py +0 -0
- {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
- {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
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/utils/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/KNOWN_ISSUES.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/README.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/conftest.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/conftest_new.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/connections-test.yaml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/pytest_plugins.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/sql_statement_lists.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_ssh_tunnels.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_system_ssh.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_cli_versions.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_clickhouse_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_config_connection.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_config_parser.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_connection_utils.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_connector_implementations.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_dbeaver_import.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_docker_connectivity.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_error_handling.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_host_mapping_flow.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_limits.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_mcp_protocol.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_mcp_server.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_postgresql_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_process_cleanup.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_result_serialization.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_layers.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_security_readonly_integration.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_serialization_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_server.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_server_selection.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/tests/test_ssh_timeout.py +0 -0
- {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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
]
|
{mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/connection.py
RENAMED
|
@@ -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
|
{mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.5}/src/mcp_read_only_sql/config/dbeaver_import.py
RENAMED
|
@@ -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(
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
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")
|
|
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(
|
|
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 =
|
|
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)
|