mcp-read-only-sql 0.2.1__tar.gz → 0.2.2__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.2}/AGENTS.md +5 -3
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/CHANGELOG.md +16 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/PKG-INFO +2 -1
- mcp_read_only_sql-0.2.2/RELEASING.md +76 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/pyproject.toml +5 -1
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/connection.py +1 -1
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/dbeaver_import.py +54 -31
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/loader.py +7 -5
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +74 -48
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/python.py +3 -3
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/cli.py +52 -36
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/python.py +6 -3
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/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.2}/src/mcp_read_only_sql/utils/json_serializer.py +3 -3
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/ssh_tunnel.py +5 -2
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/timeout_wrapper.py +9 -8
- mcp_read_only_sql-0.2.1/RELEASING.md +0 -109
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.github/workflows/publish.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.github/workflows/test.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.gitignore +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.mcp.json +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/LICENSE +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/README.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/conftest.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/clickhouse/init/01_init.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/postgres/init/01_schema.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/postgres/init/02_data.sql +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/ssh/Dockerfile +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker-compose.yml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/justfile +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/pytest.ini +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/run_tests.sh +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/parser.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/base.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/base_cli.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/server.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/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.2}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/KNOWN_ISSUES.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/README.md +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/__init__.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/conftest.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/conftest_new.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/connections-test.yaml +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/pytest_plugins.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/sql_statement_lists.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_ssh_tunnels.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_system_ssh.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_versions.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_clickhouse_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_config_connection.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_config_parser.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_connection_utils.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_connector_implementations.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_dbeaver_import.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_docker_connectivity.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_docker_test_config.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_error_handling.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_host_mapping_flow.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_limits.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_mcp_protocol.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_mcp_server.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_postgresql_cli_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_process_cleanup.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_result_serialization.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_run_query_file_output.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_runtime_paths.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_layers.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_readonly.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_readonly_integration.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_serialization_fallback.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_server.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_server_selection.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_ssh_timeout.py +0 -0
- {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/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,22 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-04-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added `ty` as a supported development check for the full packaged `src/` tree.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Added repo-specific `AGENTS.md` guidance covering connector layout, shared timeout and SSH helpers, and the typed development workflow.
|
|
19
|
+
- Reworked `RELEASING.md` into an evergreen release checklist with explicit validation, tagging, and publish steps.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Flushed the final buffered TSV line when PostgreSQL and ClickHouse CLI queries stream results to an output file.
|
|
24
|
+
- Hardened DBeaver credential import so missing or non-dictionary decrypted sections are ignored cleanly instead of being treated as valid connection data.
|
|
25
|
+
|
|
10
26
|
## [0.2.1] - 2026-04-02
|
|
11
27
|
|
|
12
28
|
### 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.2
|
|
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,7 @@ 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'
|
|
32
33
|
Description-Content-Type: text/markdown
|
|
33
34
|
|
|
34
35
|
# 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.2"
|
|
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,7 @@ 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",
|
|
42
43
|
]
|
|
43
44
|
|
|
44
45
|
[build-system]
|
|
@@ -50,3 +51,6 @@ mcp-read-only-sql = "mcp_read_only_sql.server:main"
|
|
|
50
51
|
|
|
51
52
|
[tool.hatch.build.targets.wheel]
|
|
52
53
|
packages = ["src/mcp_read_only_sql"]
|
|
54
|
+
|
|
55
|
+
[tool.ty.src]
|
|
56
|
+
include = ["src"]
|
{mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/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.2}/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,7 +1,7 @@
|
|
|
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
|
|
|
@@ -35,16 +35,18 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
|
|
|
35
35
|
if not isinstance(raw_configs, list):
|
|
36
36
|
raise ValueError("Configuration file must contain a list of connections")
|
|
37
37
|
|
|
38
|
-
connections = {}
|
|
39
|
-
errors = []
|
|
38
|
+
connections: Dict[str, Connection] = {}
|
|
39
|
+
errors: list[str] = []
|
|
40
40
|
|
|
41
41
|
for idx, config in enumerate(raw_configs):
|
|
42
42
|
if not isinstance(config, dict):
|
|
43
43
|
errors.append(f"Connection #{idx+1}: must be a dictionary")
|
|
44
44
|
continue
|
|
45
45
|
|
|
46
|
+
config_dict = cast(Dict[str, Any], config)
|
|
47
|
+
|
|
46
48
|
try:
|
|
47
|
-
conn = Connection(
|
|
49
|
+
conn = Connection(config_dict)
|
|
48
50
|
|
|
49
51
|
# Check for duplicate names
|
|
50
52
|
if conn.name in connections:
|
|
@@ -54,7 +56,7 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
|
|
|
54
56
|
|
|
55
57
|
except Exception as e:
|
|
56
58
|
# Get connection name if available for better error messages
|
|
57
|
-
name =
|
|
59
|
+
name = config_dict.get("connection_name", f"#{idx+1}")
|
|
58
60
|
errors.append(f"Connection '{name}': {e}")
|
|
59
61
|
|
|
60
62
|
if errors:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
-
from contextlib import asynccontextmanager,
|
|
4
|
+
from contextlib import asynccontextmanager, suppress
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Optional
|
|
6
|
+
from typing import Callable, Optional
|
|
7
7
|
|
|
8
8
|
from ..base_cli import BaseCLIConnector
|
|
9
9
|
from ...utils.ssh_tunnel_cli import CLISSHTunnel
|
|
@@ -55,9 +55,10 @@ class ClickHouseCLIConnector(BaseCLIConnector):
|
|
|
55
55
|
self, query: str, database: Optional[str] = None, server: Optional[str] = None
|
|
56
56
|
) -> str:
|
|
57
57
|
"""Execute a read-only query using clickhouse-client and return raw TSV output"""
|
|
58
|
-
|
|
58
|
+
result = await self._run_query(
|
|
59
59
|
query, database=database, server=server, output_path=None
|
|
60
60
|
)
|
|
61
|
+
return result if result is not None else ""
|
|
61
62
|
|
|
62
63
|
async def execute_query_to_file(
|
|
63
64
|
self,
|
|
@@ -162,35 +163,38 @@ class ClickHouseCLIConnector(BaseCLIConnector):
|
|
|
162
163
|
if asyncio.iscoroutine(drain_result):
|
|
163
164
|
await drain_result
|
|
164
165
|
stdin.close()
|
|
166
|
+
elif self.password:
|
|
167
|
+
process.kill()
|
|
168
|
+
await process.wait()
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"clickhouse-client: failed to create subprocess stdin pipe"
|
|
171
|
+
)
|
|
165
172
|
|
|
166
173
|
stdout = process.stdout
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
nonlocal wrote_content
|
|
175
|
-
if output_path is None:
|
|
176
|
-
lines.append(line)
|
|
177
|
-
else:
|
|
178
|
-
wrote_content = write_tsv_text_line(handle, line, wrote_content)
|
|
174
|
+
stderr_stream = process.stderr
|
|
175
|
+
if stdout is None or stderr_stream is None:
|
|
176
|
+
process.kill()
|
|
177
|
+
await process.wait()
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"clickhouse-client: failed to create subprocess pipes"
|
|
180
|
+
)
|
|
179
181
|
|
|
182
|
+
stderr_task = asyncio.create_task(stderr_stream.read())
|
|
183
|
+
lines: list[str] = []
|
|
180
184
|
loop = asyncio.get_event_loop()
|
|
181
185
|
deadline = loop.time() + self.query_timeout
|
|
182
186
|
|
|
183
|
-
async def
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
187
|
+
async def stream_output(emit_line: Callable[[str], None]) -> str | None:
|
|
188
|
+
pending_line: str | None = None
|
|
189
|
+
|
|
190
|
+
async def read_line_with_timeout() -> bytes:
|
|
191
|
+
remaining = deadline - loop.time()
|
|
192
|
+
if remaining <= 0:
|
|
193
|
+
raise asyncio.TimeoutError
|
|
194
|
+
return await asyncio.wait_for(
|
|
195
|
+
stdout.readline(), timeout=remaining
|
|
196
|
+
)
|
|
197
|
+
|
|
194
198
|
try:
|
|
195
199
|
while True:
|
|
196
200
|
line_bytes = await read_line_with_timeout()
|
|
@@ -202,7 +206,6 @@ class ClickHouseCLIConnector(BaseCLIConnector):
|
|
|
202
206
|
if pending_line is not None:
|
|
203
207
|
emit_line(pending_line)
|
|
204
208
|
pending_line = line
|
|
205
|
-
|
|
206
209
|
except asyncio.TimeoutError:
|
|
207
210
|
logger.warning(
|
|
208
211
|
"Query timeout - terminating clickhouse-client process"
|
|
@@ -217,34 +220,57 @@ class ClickHouseCLIConnector(BaseCLIConnector):
|
|
|
217
220
|
raise TimeoutError(
|
|
218
221
|
f"clickhouse-client: Query timeout after {self.query_timeout}s"
|
|
219
222
|
)
|
|
223
|
+
return pending_line
|
|
220
224
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
async def finalize_process(
|
|
226
|
+
emit_line: Callable[[str], None], pending_line: str | None
|
|
227
|
+
) -> None:
|
|
228
|
+
try:
|
|
229
|
+
await asyncio.wait_for(process.wait(), timeout=1.0)
|
|
230
|
+
except asyncio.TimeoutError:
|
|
231
|
+
logger.error(
|
|
232
|
+
"clickhouse-client process did not terminate cleanly"
|
|
233
|
+
)
|
|
234
|
+
process.kill()
|
|
235
|
+
await process.wait()
|
|
227
236
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
237
|
+
try:
|
|
238
|
+
stderr = (
|
|
239
|
+
await stderr_task
|
|
240
|
+
if not stderr_task.done()
|
|
241
|
+
else stderr_task.result()
|
|
242
|
+
)
|
|
243
|
+
except asyncio.CancelledError:
|
|
244
|
+
stderr = b""
|
|
232
245
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
246
|
+
returncode = process.returncode
|
|
247
|
+
if returncode is None:
|
|
248
|
+
logger.debug(
|
|
249
|
+
"clickhouse-client process still running after wait(); treating as successful termination"
|
|
250
|
+
)
|
|
251
|
+
if returncode not in (0, None):
|
|
252
|
+
error_msg = stderr.decode() if stderr else "Unknown error"
|
|
253
|
+
logger.error(f"clickhouse-client error: {error_msg}")
|
|
254
|
+
raise RuntimeError(f"clickhouse-client: {error_msg}")
|
|
242
255
|
|
|
243
|
-
|
|
244
|
-
|
|
256
|
+
if pending_line not in (None, ""):
|
|
257
|
+
emit_line(pending_line)
|
|
245
258
|
|
|
246
259
|
if output_path is None:
|
|
260
|
+
pending_line = await stream_output(lines.append)
|
|
261
|
+
await finalize_process(lines.append, pending_line)
|
|
247
262
|
return "\n".join(lines)
|
|
263
|
+
|
|
264
|
+
wrote_content = False
|
|
265
|
+
|
|
266
|
+
def emit_file_line(line: str) -> None:
|
|
267
|
+
nonlocal wrote_content
|
|
268
|
+
wrote_content = write_tsv_text_line(handle, line, wrote_content)
|
|
269
|
+
|
|
270
|
+
assert output_path is not None
|
|
271
|
+
with Path(output_path).open("w", encoding="utf-8", newline="") as handle:
|
|
272
|
+
pending_line = await stream_output(emit_file_line)
|
|
273
|
+
await finalize_process(emit_file_line, pending_line)
|
|
248
274
|
return None
|
|
249
275
|
|
|
250
276
|
except FileNotFoundError:
|
|
@@ -234,7 +234,7 @@ class ClickHousePythonConnector(BaseConnector):
|
|
|
234
234
|
port: int,
|
|
235
235
|
database: str,
|
|
236
236
|
query: str,
|
|
237
|
-
original_port: int = None,
|
|
237
|
+
original_port: Optional[int] = None,
|
|
238
238
|
is_ssh_tunnel: bool = False,
|
|
239
239
|
output_path: Optional[str] = None,
|
|
240
240
|
) -> str:
|
|
@@ -294,9 +294,9 @@ class ClickHousePythonConnector(BaseConnector):
|
|
|
294
294
|
port: int,
|
|
295
295
|
database: str,
|
|
296
296
|
query: str,
|
|
297
|
-
original_port: int = None,
|
|
297
|
+
original_port: Optional[int] = None,
|
|
298
298
|
is_ssh_tunnel: bool = False,
|
|
299
|
-
output_path:
|
|
299
|
+
output_path: str = "",
|
|
300
300
|
) -> None:
|
|
301
301
|
"""Execute query synchronously and stream raw TSV output to a file."""
|
|
302
302
|
client = None
|