sqlbuddy-nwi 0.3.3__tar.gz → 0.4.0__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.
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/PKG-INFO +34 -14
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/README.md +33 -12
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/_version.py +1 -1
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/cli.py +26 -1
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/manager.py +34 -33
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/wrappers.py +11 -14
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/db_query.py +60 -66
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/pyproject.toml +7 -2
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/server.py +19 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/PKG-INFO +34 -14
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/SOURCES.txt +6 -1
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/requires.txt +0 -1
- sqlbuddy_nwi-0.4.0/tests/test_client_timeouts.py +103 -0
- sqlbuddy_nwi-0.4.0/tests/test_http_server.py +114 -0
- sqlbuddy_nwi-0.4.0/tests/test_query_rows.py +142 -0
- sqlbuddy_nwi-0.4.0/tests/test_tunnel.py +609 -0
- sqlbuddy_nwi-0.4.0/tests/test_version_sync.py +61 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tunnel.py +17 -2
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/LICENSE +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/__init__.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/config_util.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/readonly.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/runtime.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/db_security.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/init.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/setup.cfg +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/dependency_links.txt +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/entry_points.txt +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/top_level.txt +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/test_vendor.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tests/test_get_info.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tests/test_parse_create_table.py +0 -0
- {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tests/test_sanitize_search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlbuddy-nwi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: MCP server for read-only database access (MySQL, ClickHouse)
|
|
5
5
|
Author: ying.yuxiang
|
|
6
6
|
License: MIT
|
|
@@ -23,7 +23,6 @@ License-File: LICENSE
|
|
|
23
23
|
Requires-Dist: mcp<2,>=1.0.0
|
|
24
24
|
Requires-Dist: pymysql>=1.1.0
|
|
25
25
|
Requires-Dist: clickhouse-driver>=0.2.6
|
|
26
|
-
Requires-Dist: pandas>=2.0.0
|
|
27
26
|
Requires-Dist: platformdirs>=4.0.0
|
|
28
27
|
Dynamic: license-file
|
|
29
28
|
|
|
@@ -67,19 +66,40 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
|
|
|
67
66
|
|
|
68
67
|
## Quickstart
|
|
69
68
|
|
|
69
|
+
**Install** (current release [](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
|
|
70
|
+
|
|
70
71
|
```bash
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
uv run sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
|
|
72
|
+
# From PyPI (recommended):
|
|
73
|
+
pip install sqlbuddy-nwi # provides the `sql-buddy` command
|
|
74
|
+
# …or run the latest instantly without installing:
|
|
75
|
+
uvx --from sqlbuddy-nwi sql-buddy
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
```bash
|
|
79
|
+
# From source (for development):
|
|
80
|
+
git clone https://github.com/yyx462/sql-buddy && cd sql-buddy
|
|
81
|
+
uv run server.py # auto-provisions an isolated .venv, then serves
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Configure & connect:**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
sql-buddy init # interactive wizard → writes connections/config/db.ini
|
|
88
|
+
sql-buddy doctor --connect # green-checks db.ini, tunnels, MCP client config
|
|
89
|
+
sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> From a **git checkout**, prefix these with `uv run` (e.g. `uv run sql-buddy init`).
|
|
93
|
+
> From a **pip / uvx install**, run `sql-buddy` directly — it's on your PATH.
|
|
94
|
+
|
|
78
95
|
Restart your client, then ask your agent *"list the tables in the `ck`
|
|
79
96
|
connection"* — if it answers, you're live. 🎉
|
|
80
97
|
|
|
81
98
|
> **Bare `sql-buddy` (no command) runs the MCP server** — that's what your AI
|
|
82
99
|
> client invokes, so configs hold no fragile absolute `.venv/bin/python` path.
|
|
100
|
+
> `sql-buddy mcp add` auto-detects how you installed and writes the right
|
|
101
|
+
> command: `uvx --from sqlbuddy-nwi sql-buddy` for a pip/uvx install,
|
|
102
|
+
> `uv --directory … run server.py` for a checkout.
|
|
83
103
|
> Other commands: `version`, `doctor [--connect]`, `mcp print|add`, `update-check`, `init`.
|
|
84
104
|
> On **Windows**, see [`docs/windows-trae.md`](docs/windows-trae.md) and use
|
|
85
105
|
> `sql-buddy mcp add --client trae`.
|
|
@@ -91,8 +111,8 @@ connection"* — if it answers, you're live. 🎉
|
|
|
91
111
|
Run the wizard (easiest) or copy the template:
|
|
92
112
|
|
|
93
113
|
```bash
|
|
94
|
-
|
|
95
|
-
# or by hand:
|
|
114
|
+
sql-buddy init
|
|
115
|
+
# or, from a git checkout, copy the template and edit by hand:
|
|
96
116
|
cp connections/config/db.ini.example connections/config/db.ini
|
|
97
117
|
$EDITOR connections/config/db.ini
|
|
98
118
|
```
|
|
@@ -140,13 +160,13 @@ reference, SSH troubleshooting, and External (`ssh -N`) mode, see
|
|
|
140
160
|
## Connect your AI agent
|
|
141
161
|
|
|
142
162
|
```bash
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
sql-buddy mcp add # Claude Code project → ./.mcp.json
|
|
164
|
+
sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
|
|
165
|
+
sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
|
|
166
|
+
sql-buddy mcp add --client trae --scope user # Trae user-global
|
|
147
167
|
```
|
|
148
168
|
|
|
149
|
-
Restart your client. Preview the exact block first with `
|
|
169
|
+
Restart your client. Preview the exact block first with `sql-buddy mcp print`
|
|
150
170
|
(paths filled in):
|
|
151
171
|
|
|
152
172
|
```json
|
|
@@ -38,19 +38,40 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
|
|
|
38
38
|
|
|
39
39
|
## Quickstart
|
|
40
40
|
|
|
41
|
+
**Install** (current release [](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
|
|
42
|
+
|
|
41
43
|
```bash
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
uv run sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
|
|
44
|
+
# From PyPI (recommended):
|
|
45
|
+
pip install sqlbuddy-nwi # provides the `sql-buddy` command
|
|
46
|
+
# …or run the latest instantly without installing:
|
|
47
|
+
uvx --from sqlbuddy-nwi sql-buddy
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
```bash
|
|
51
|
+
# From source (for development):
|
|
52
|
+
git clone https://github.com/yyx462/sql-buddy && cd sql-buddy
|
|
53
|
+
uv run server.py # auto-provisions an isolated .venv, then serves
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Configure & connect:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
sql-buddy init # interactive wizard → writes connections/config/db.ini
|
|
60
|
+
sql-buddy doctor --connect # green-checks db.ini, tunnels, MCP client config
|
|
61
|
+
sql-buddy mcp add # registers sql-buddy with Claude Code (writes .mcp.json)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> From a **git checkout**, prefix these with `uv run` (e.g. `uv run sql-buddy init`).
|
|
65
|
+
> From a **pip / uvx install**, run `sql-buddy` directly — it's on your PATH.
|
|
66
|
+
|
|
49
67
|
Restart your client, then ask your agent *"list the tables in the `ck`
|
|
50
68
|
connection"* — if it answers, you're live. 🎉
|
|
51
69
|
|
|
52
70
|
> **Bare `sql-buddy` (no command) runs the MCP server** — that's what your AI
|
|
53
71
|
> client invokes, so configs hold no fragile absolute `.venv/bin/python` path.
|
|
72
|
+
> `sql-buddy mcp add` auto-detects how you installed and writes the right
|
|
73
|
+
> command: `uvx --from sqlbuddy-nwi sql-buddy` for a pip/uvx install,
|
|
74
|
+
> `uv --directory … run server.py` for a checkout.
|
|
54
75
|
> Other commands: `version`, `doctor [--connect]`, `mcp print|add`, `update-check`, `init`.
|
|
55
76
|
> On **Windows**, see [`docs/windows-trae.md`](docs/windows-trae.md) and use
|
|
56
77
|
> `sql-buddy mcp add --client trae`.
|
|
@@ -62,8 +83,8 @@ connection"* — if it answers, you're live. 🎉
|
|
|
62
83
|
Run the wizard (easiest) or copy the template:
|
|
63
84
|
|
|
64
85
|
```bash
|
|
65
|
-
|
|
66
|
-
# or by hand:
|
|
86
|
+
sql-buddy init
|
|
87
|
+
# or, from a git checkout, copy the template and edit by hand:
|
|
67
88
|
cp connections/config/db.ini.example connections/config/db.ini
|
|
68
89
|
$EDITOR connections/config/db.ini
|
|
69
90
|
```
|
|
@@ -111,13 +132,13 @@ reference, SSH troubleshooting, and External (`ssh -N`) mode, see
|
|
|
111
132
|
## Connect your AI agent
|
|
112
133
|
|
|
113
134
|
```bash
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
sql-buddy mcp add # Claude Code project → ./.mcp.json
|
|
136
|
+
sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
|
|
137
|
+
sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
|
|
138
|
+
sql-buddy mcp add --client trae --scope user # Trae user-global
|
|
118
139
|
```
|
|
119
140
|
|
|
120
|
-
Restart your client. Preview the exact block first with `
|
|
141
|
+
Restart your client. Preview the exact block first with `sql-buddy mcp print`
|
|
121
142
|
(paths filled in):
|
|
122
143
|
|
|
123
144
|
```json
|
|
@@ -44,7 +44,6 @@ _RUNTIME_DEPS = [
|
|
|
44
44
|
("mcp", "mcp.server.fastmcp"),
|
|
45
45
|
("pymysql", None),
|
|
46
46
|
("clickhouse_driver", None),
|
|
47
|
-
("pandas", None),
|
|
48
47
|
("platformdirs", None),
|
|
49
48
|
]
|
|
50
49
|
|
|
@@ -1025,6 +1024,10 @@ sql-buddy {ver} — read-only MCP database server
|
|
|
1025
1024
|
|
|
1026
1025
|
Usage:
|
|
1027
1026
|
sql-buddy [serve|stdio] run the MCP server over stdio (default)
|
|
1027
|
+
sql-buddy http [--host H] [--port P]
|
|
1028
|
+
run ONE shared MCP server over streamable
|
|
1029
|
+
HTTP — N agents, one process, one tunnel
|
|
1030
|
+
(default 127.0.0.1:7801; see docs/setup.md)
|
|
1028
1031
|
sql-buddy version print version
|
|
1029
1032
|
sql-buddy doctor [--connect] validate install + db.ini (+ live pings)
|
|
1030
1033
|
sql-buddy update-check compare local checkout to the git remote
|
|
@@ -1057,10 +1060,32 @@ def _run_server() -> int:
|
|
|
1057
1060
|
return 0
|
|
1058
1061
|
|
|
1059
1062
|
|
|
1063
|
+
def _run_http(host: str, port: int) -> int:
|
|
1064
|
+
import server
|
|
1065
|
+
server.run_http(host=host, port=port)
|
|
1066
|
+
return 0
|
|
1067
|
+
|
|
1068
|
+
|
|
1060
1069
|
def main(argv: list[str] | None = None) -> int:
|
|
1061
1070
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
1062
1071
|
if not args or args[0] in ("serve", "server", "stdio"):
|
|
1063
1072
|
return _run_server()
|
|
1073
|
+
if args[0] == "http":
|
|
1074
|
+
host, port = "127.0.0.1", 7801
|
|
1075
|
+
it = iter(args[1:])
|
|
1076
|
+
for flag in it:
|
|
1077
|
+
if flag == "--host":
|
|
1078
|
+
host = next(it, host)
|
|
1079
|
+
elif flag == "--port":
|
|
1080
|
+
raw = next(it, "")
|
|
1081
|
+
if not raw.isdigit():
|
|
1082
|
+
print("error: --port needs an integer", file=sys.stderr)
|
|
1083
|
+
return 2
|
|
1084
|
+
port = int(raw)
|
|
1085
|
+
else:
|
|
1086
|
+
print(f"error: unknown http option '{flag}'", file=sys.stderr)
|
|
1087
|
+
return 2
|
|
1088
|
+
return _run_http(host, port)
|
|
1064
1089
|
|
|
1065
1090
|
cmd, rest = args[0], args[1:]
|
|
1066
1091
|
|
|
@@ -10,7 +10,6 @@ generation bumps (the previous TCP socket is dead after a restart).
|
|
|
10
10
|
import configparser
|
|
11
11
|
import os
|
|
12
12
|
import sys
|
|
13
|
-
import uuid
|
|
14
13
|
from contextlib import contextmanager
|
|
15
14
|
from typing import Any, Dict, Tuple
|
|
16
15
|
|
|
@@ -29,7 +28,21 @@ from .wrappers import (
|
|
|
29
28
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
30
29
|
if _REPO_ROOT not in sys.path:
|
|
31
30
|
sys.path.insert(0, _REPO_ROOT)
|
|
32
|
-
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_tunnel_manager():
|
|
34
|
+
"""Return the process-wide TunnelManager (imported lazily).
|
|
35
|
+
|
|
36
|
+
A module-level `from tunnel import get_manager` here creates a genuine
|
|
37
|
+
import cycle: `import tunnel` -> `connections.config_util` ->
|
|
38
|
+
`connections/__init__` -> manager -> `from tunnel import get_manager`
|
|
39
|
+
while tunnel is still mid-initialization. tunnel.py's `except ImportError`
|
|
40
|
+
then silently swallows the collision and its config helpers stay None,
|
|
41
|
+
breaking every TunnelManager built in that process. Deferring the import
|
|
42
|
+
to call time breaks the cycle, so `import tunnel` works in any order.
|
|
43
|
+
"""
|
|
44
|
+
from tunnel import get_manager
|
|
45
|
+
return get_manager()
|
|
33
46
|
|
|
34
47
|
|
|
35
48
|
DEFAULT_REMOTE_DB_CREDS: Dict[str, Tuple[str, str]] = {}
|
|
@@ -135,10 +148,27 @@ class DBManager:
|
|
|
135
148
|
return conn_config
|
|
136
149
|
|
|
137
150
|
def _create_clickhouse_client(self, section: str) -> Client:
|
|
138
|
-
|
|
151
|
+
cfg = self._resolve_conn_config(section, 9000)
|
|
152
|
+
# Default timeouts: a black-holed endpoint (dead tunnel forward, silent
|
|
153
|
+
# DB) must raise instead of parking the caller's worker thread in a
|
|
154
|
+
# socket read forever. Explicit keys in the db.ini section win.
|
|
155
|
+
cfg.setdefault('connect_timeout', 10)
|
|
156
|
+
cfg.setdefault('send_receive_timeout', 900)
|
|
157
|
+
for k in ('connect_timeout', 'send_receive_timeout'):
|
|
158
|
+
cfg[k] = int(cfg[k]) # ini values arrive as strings; drivers want ints
|
|
159
|
+
return Client(**cfg)
|
|
139
160
|
|
|
140
161
|
def _create_mysql_client(self, section: str) -> MySQLConnectionWrapper:
|
|
141
|
-
|
|
162
|
+
cfg = self._resolve_conn_config(section, 3306)
|
|
163
|
+
# Same bounds for MySQL; read/write must exceed the 600s
|
|
164
|
+
# innodb_lock_wait_timeout the locked wrapper sets, so legit
|
|
165
|
+
# lock-waits are not cut short.
|
|
166
|
+
cfg.setdefault('connect_timeout', 10)
|
|
167
|
+
cfg.setdefault('read_timeout', 900)
|
|
168
|
+
cfg.setdefault('write_timeout', 900)
|
|
169
|
+
for k in ('connect_timeout', 'read_timeout', 'write_timeout'):
|
|
170
|
+
cfg[k] = int(cfg[k]) # ini values arrive as strings; drivers want ints
|
|
171
|
+
return MySQLConnectionWrapper(pymysql.connect(**cfg))
|
|
142
172
|
|
|
143
173
|
def all_clients(self) -> Dict[str, Any]:
|
|
144
174
|
return {name: entry['client'] for name, entry in self._cache.items()}
|
|
@@ -151,35 +181,6 @@ class DBManager:
|
|
|
151
181
|
elif hasattr(client, "close"):
|
|
152
182
|
client.close()
|
|
153
183
|
|
|
154
|
-
@contextmanager
|
|
155
|
-
def session_scope(self, client_name: str = 'ck'):
|
|
156
|
-
client = getattr(self, client_name)
|
|
157
|
-
session_id = str(uuid.uuid4())
|
|
158
|
-
original_execute = client.execute
|
|
159
|
-
original_query_dataframe = getattr(client, 'query_dataframe', None)
|
|
160
|
-
|
|
161
|
-
def scoped_execute(query, params=None, settings=None, **kwargs):
|
|
162
|
-
if settings is None:
|
|
163
|
-
settings = {}
|
|
164
|
-
settings['session_id'] = session_id
|
|
165
|
-
return original_execute(query, params=params, settings=settings, **kwargs)
|
|
166
|
-
|
|
167
|
-
def scoped_query_dataframe(query, params=None, settings=None, **kwargs):
|
|
168
|
-
if settings is None:
|
|
169
|
-
settings = {}
|
|
170
|
-
settings['session_id'] = session_id
|
|
171
|
-
return original_query_dataframe(query, params=params, settings=settings, **kwargs)
|
|
172
|
-
|
|
173
|
-
class SessionClient:
|
|
174
|
-
def execute(self, *args, **kwargs):
|
|
175
|
-
return scoped_execute(*args, **kwargs)
|
|
176
|
-
def query_dataframe(self, *args, **kwargs):
|
|
177
|
-
if original_query_dataframe:
|
|
178
|
-
return scoped_query_dataframe(*args, **kwargs)
|
|
179
|
-
raise AttributeError("query_dataframe not available on this client")
|
|
180
|
-
|
|
181
|
-
yield SessionClient()
|
|
182
|
-
|
|
183
184
|
@contextmanager
|
|
184
185
|
def transaction_scope(self, connection: str):
|
|
185
186
|
conn = getattr(self, connection)
|
|
@@ -10,7 +10,6 @@ See UBIQUITOUS_LANGUAGE.md: "Locked" == the wrapper layer enforcing Read-only.
|
|
|
10
10
|
|
|
11
11
|
import pymysql
|
|
12
12
|
from clickhouse_driver import Client
|
|
13
|
-
import pandas as pd
|
|
14
13
|
|
|
15
14
|
from .readonly import _filter_sql
|
|
16
15
|
|
|
@@ -31,12 +30,19 @@ class MySQLConnectionWrapper:
|
|
|
31
30
|
finally:
|
|
32
31
|
cursor.close()
|
|
33
32
|
|
|
34
|
-
def
|
|
33
|
+
def query_rows(self, sql: str):
|
|
34
|
+
"""Read `sql` and return (column_names, rows_as_dicts).
|
|
35
|
+
|
|
36
|
+
Replaces the old `query_dataframe` (which wrapped these same rows in a
|
|
37
|
+
pandas DataFrame — a 45 MiB import per process). Same DictCursor
|
|
38
|
+
semantics, plain dicts out.
|
|
39
|
+
"""
|
|
35
40
|
cursor = self._conn.cursor(pymysql.cursors.DictCursor)
|
|
36
41
|
try:
|
|
37
42
|
cursor.execute(sql)
|
|
38
43
|
rows = cursor.fetchall()
|
|
39
|
-
|
|
44
|
+
columns = [d[0] for d in cursor.description] if cursor.description else []
|
|
45
|
+
return columns, list(rows)
|
|
40
46
|
finally:
|
|
41
47
|
cursor.close()
|
|
42
48
|
|
|
@@ -68,9 +74,9 @@ class LockedMySQLConnectionWrapper:
|
|
|
68
74
|
_filter_sql(sql)
|
|
69
75
|
return self._wrapped.db_execute(sql, params)
|
|
70
76
|
|
|
71
|
-
def
|
|
77
|
+
def query_rows(self, sql: str):
|
|
72
78
|
_filter_sql(sql)
|
|
73
|
-
return self._wrapped.
|
|
79
|
+
return self._wrapped.query_rows(sql)
|
|
74
80
|
|
|
75
81
|
|
|
76
82
|
class LockedClickHouseClient:
|
|
@@ -88,15 +94,6 @@ class LockedClickHouseClient:
|
|
|
88
94
|
settings=settings, **kwargs
|
|
89
95
|
)
|
|
90
96
|
|
|
91
|
-
def query_dataframe(self, query, params=None, settings=None, **kwargs):
|
|
92
|
-
_filter_sql(query)
|
|
93
|
-
result = self._wrapped.execute(query, params=params, settings=settings, with_column_types=True, **kwargs)
|
|
94
|
-
if result:
|
|
95
|
-
data, columns = result
|
|
96
|
-
if data:
|
|
97
|
-
return pd.DataFrame(data, columns=[c[0] for c in columns])
|
|
98
|
-
return pd.DataFrame()
|
|
99
|
-
|
|
100
97
|
def disconnect(self):
|
|
101
98
|
return self._wrapped.disconnect()
|
|
102
99
|
|
|
@@ -135,31 +135,33 @@ def _is_query_allowed(sql: str) -> bool:
|
|
|
135
135
|
return any(sql_trimmed.startswith(p) for p in allowed)
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def _fetch_rows(client, sql: str):
|
|
139
|
+
"""Dialect-agnostic read path: -> (column_names, rows_as_dicts). No pandas.
|
|
140
|
+
|
|
141
|
+
MySQL wrapper (raw or Locked) exposes `query_rows`; ClickHouse goes through
|
|
142
|
+
`execute(with_column_types=True)` (the Locked client filters it first).
|
|
143
|
+
Replaces the old `hasattr(client, 'query_dataframe')` DataFrame dispatch:
|
|
144
|
+
same output shape, none of the 45 MiB pandas import.
|
|
145
|
+
"""
|
|
146
|
+
if hasattr(client, 'query_rows'):
|
|
147
|
+
return client.query_rows(sql)
|
|
148
|
+
result = client.execute(sql, with_column_types=True)
|
|
149
|
+
if not result or not result[0]:
|
|
150
|
+
return [], []
|
|
151
|
+
data, columns = result
|
|
152
|
+
col_names = [c[0] for c in columns]
|
|
153
|
+
return col_names, [dict(zip(col_names, row)) for row in data]
|
|
154
|
+
|
|
155
|
+
|
|
138
156
|
def _result_to_json(client, sql: str) -> str:
|
|
139
157
|
"""Execute SQL and return JSON string with columns + rows. Decimals cast to float."""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
rows
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"rows": rows,
|
|
148
|
-
"row_count": len(rows),
|
|
149
|
-
}, default=str)
|
|
150
|
-
else:
|
|
151
|
-
# ClickHouse
|
|
152
|
-
result = client.execute(sql, with_column_types=True)
|
|
153
|
-
if not result or not result[0]:
|
|
154
|
-
return json.dumps({"columns": [], "rows": [], "row_count": 0})
|
|
155
|
-
data, columns = result
|
|
156
|
-
col_names = [c[0] for c in columns]
|
|
157
|
-
rows = [_sanitize_row(dict(zip(col_names, row))) for row in data]
|
|
158
|
-
return json.dumps({
|
|
159
|
-
"columns": col_names,
|
|
160
|
-
"rows": rows,
|
|
161
|
-
"row_count": len(rows),
|
|
162
|
-
}, default=str)
|
|
158
|
+
columns, rows = _fetch_rows(client, sql)
|
|
159
|
+
rows = [_sanitize_row(r) for r in rows]
|
|
160
|
+
return json.dumps({
|
|
161
|
+
"columns": columns,
|
|
162
|
+
"rows": rows,
|
|
163
|
+
"row_count": len(rows),
|
|
164
|
+
}, default=str)
|
|
163
165
|
|
|
164
166
|
|
|
165
167
|
# --- Schema ---
|
|
@@ -287,12 +289,10 @@ def table_schema_query(tbl_name: str, connection: str = 'ck', force_refresh: boo
|
|
|
287
289
|
db = DBManager()
|
|
288
290
|
client = getattr(db, connection)
|
|
289
291
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
else
|
|
294
|
-
result = client.execute(f"SHOW CREATE TABLE {tbl_name}")
|
|
295
|
-
schema = str(result[0][0]) if result else ""
|
|
292
|
+
# MySQL: (Table, Create Table) — the DDL is the LAST column;
|
|
293
|
+
# ClickHouse: a single `statement` column. columns[-1] serves both.
|
|
294
|
+
columns, rows = _fetch_rows(client, f"SHOW CREATE TABLE {tbl_name}")
|
|
295
|
+
schema = str(rows[0][columns[-1]]) if rows and columns else ""
|
|
296
296
|
|
|
297
297
|
schema = schema.strip().replace('statement\n', '').strip()
|
|
298
298
|
if schema and schema[0].isdigit():
|
|
@@ -323,12 +323,13 @@ def table_schema_query(tbl_name: str, connection: str = 'ck', force_refresh: boo
|
|
|
323
323
|
def _resolve_connection_type(connection: str) -> str:
|
|
324
324
|
"""Resolve a connection name to its DBMS type ('clickhouse' | 'mysql').
|
|
325
325
|
|
|
326
|
-
Replaces the brittle ``hasattr(client, 'query_dataframe')``
|
|
327
|
-
Locked wrappers
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
326
|
+
Replaces the brittle ``hasattr(client, 'query_dataframe')`` dialect
|
|
327
|
+
dispatch of the old pandas era: BOTH Locked wrappers defined it (MySQL
|
|
328
|
+
natively, ClickHouse via an adapter), so the check *always* picked the
|
|
329
|
+
MySQL branch -- even for ClickHouse, which then fabricated a fake PRIMARY
|
|
330
|
+
index from ClickHouse's stub ``SHOW INDEX``. The pandas-free `query_rows`
|
|
331
|
+
interface is MySQL-only again, but dialect is still read from the
|
|
332
|
+
section's declared `type` (with the section-name prefix fallback), the
|
|
332
333
|
same source of truth ``find_table_query`` uses. Connection names use
|
|
333
334
|
underscores; db.ini sections use dashes, so normalize the way
|
|
334
335
|
DBManager.__getattr__ does.
|
|
@@ -411,14 +412,14 @@ def table_info_query(tbl_name: str, connection: str = 'ck', force_refresh: bool
|
|
|
411
412
|
if ctype == 'mysql':
|
|
412
413
|
# MySQL: SHOW INDEX returns real BTREE indexes with real cardinality.
|
|
413
414
|
try:
|
|
414
|
-
|
|
415
|
-
info['row_count'] = int(
|
|
415
|
+
_cols, rows = _fetch_rows(client, f"SELECT COUNT(*) as cnt FROM {tbl_name}")
|
|
416
|
+
info['row_count'] = int(rows[0]['cnt']) if rows else 0
|
|
416
417
|
except Exception:
|
|
417
418
|
info['row_count'] = None
|
|
418
419
|
try:
|
|
419
|
-
|
|
420
|
-
if
|
|
421
|
-
info['indexes'] =
|
|
420
|
+
_cols, rows = _fetch_rows(client, f"SHOW INDEX FROM {tbl_name}")
|
|
421
|
+
if rows:
|
|
422
|
+
info['indexes'] = rows
|
|
422
423
|
except Exception:
|
|
423
424
|
info['indexes'] = []
|
|
424
425
|
else:
|
|
@@ -501,10 +502,7 @@ def check_connection_query(connection: str = 'ck') -> str:
|
|
|
501
502
|
start = time.time()
|
|
502
503
|
db = DBManager()
|
|
503
504
|
client = getattr(db, connection) # raises AttributeError if unknown; starts tunnel if Auto
|
|
504
|
-
|
|
505
|
-
client.query_dataframe('SELECT 1')
|
|
506
|
-
else:
|
|
507
|
-
client.execute('SELECT 1')
|
|
505
|
+
_fetch_rows(client, 'SELECT 1')
|
|
508
506
|
return json.dumps({
|
|
509
507
|
"connection": connection,
|
|
510
508
|
"status": "ok",
|
|
@@ -518,18 +516,18 @@ def list_tables(connection: str = 'ck', database: str | None = None, like: str |
|
|
|
518
516
|
db = DBManager()
|
|
519
517
|
client = getattr(db, connection)
|
|
520
518
|
|
|
521
|
-
if hasattr(client, '
|
|
519
|
+
if hasattr(client, 'query_rows'):
|
|
522
520
|
# MySQL
|
|
523
521
|
sql = "SHOW TABLES"
|
|
524
522
|
if database:
|
|
525
523
|
sql = f"SHOW TABLES FROM `{database}`"
|
|
526
524
|
if like:
|
|
527
525
|
sql += f" LIKE '{like}'"
|
|
528
|
-
|
|
529
|
-
if
|
|
526
|
+
columns, rows = _fetch_rows(client, sql)
|
|
527
|
+
if not rows:
|
|
530
528
|
return json.dumps({"tables": [], "count": 0})
|
|
531
|
-
|
|
532
|
-
tables =
|
|
529
|
+
first = columns[0] if columns else ''
|
|
530
|
+
tables = [str(r.get(first)) for r in rows]
|
|
533
531
|
else:
|
|
534
532
|
# ClickHouse
|
|
535
533
|
if database:
|
|
@@ -614,17 +612,16 @@ def _full_catalog_query(ctype: str) -> str:
|
|
|
614
612
|
|
|
615
613
|
def _exec_catalog(client, sql: str) -> list:
|
|
616
614
|
"""Run a catalog SELECT; return list of (database, table, engine) tuples."""
|
|
617
|
-
if hasattr(client, '
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
return out
|
|
615
|
+
if hasattr(client, 'query_rows'):
|
|
616
|
+
_cols, rows = _fetch_rows(client, sql)
|
|
617
|
+
return [
|
|
618
|
+
(
|
|
619
|
+
str(r.get('db_name')) if r.get('db_name') is not None else '',
|
|
620
|
+
str(r.get('tbl_name')) if r.get('tbl_name') is not None else '',
|
|
621
|
+
str(r.get('engine')) if r.get('engine') is not None else '',
|
|
622
|
+
)
|
|
623
|
+
for r in rows
|
|
624
|
+
]
|
|
628
625
|
result = client.execute(sql, with_column_types=True)
|
|
629
626
|
if not result or not result[0]:
|
|
630
627
|
return []
|
|
@@ -757,12 +754,9 @@ def explain_query(sql: str, connection: str = 'ck') -> str:
|
|
|
757
754
|
db = DBManager()
|
|
758
755
|
client = getattr(db, connection)
|
|
759
756
|
|
|
760
|
-
if hasattr(client, '
|
|
761
|
-
|
|
762
|
-
if
|
|
763
|
-
return json.dumps({"plan": [], "raw": ""})
|
|
764
|
-
col = result.columns[0]
|
|
765
|
-
plan_lines = result[col].tolist()
|
|
757
|
+
if hasattr(client, 'query_rows'):
|
|
758
|
+
columns, rows = _fetch_rows(client, explain_sql)
|
|
759
|
+
plan_lines = [row[columns[0]] for row in rows] if rows and columns else []
|
|
766
760
|
else:
|
|
767
761
|
result = client.execute(explain_sql)
|
|
768
762
|
plan_lines = [row[0] for row in result] if result else []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlbuddy-nwi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "MCP server for read-only database access (MySQL, ClickHouse)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -25,7 +25,6 @@ dependencies = [
|
|
|
25
25
|
"mcp>=1.0.0,<2",
|
|
26
26
|
"pymysql>=1.1.0",
|
|
27
27
|
"clickhouse-driver>=0.2.6",
|
|
28
|
-
"pandas>=2.0.0",
|
|
29
28
|
"platformdirs>=4.0.0",
|
|
30
29
|
]
|
|
31
30
|
|
|
@@ -54,3 +53,9 @@ packages = ["connections"]
|
|
|
54
53
|
[project.scripts]
|
|
55
54
|
sql-buddy = "cli:main"
|
|
56
55
|
sql-buddy-init = "init:main"
|
|
56
|
+
|
|
57
|
+
# Only `tests/` holds pytest tests. Without this, bare `pytest` also collects
|
|
58
|
+
# the root-level test_vendor.py script, whose import-time sys.exit(1) crashes
|
|
59
|
+
# the whole run with an INTERNALERROR.
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
@@ -263,6 +263,25 @@ def run_server():
|
|
|
263
263
|
mcp.run(transport="stdio")
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
def run_http(host: str = "127.0.0.1", port: int = 7801):
|
|
267
|
+
"""Run ONE shared MCP server over streamable HTTP (`sql-buddy http`).
|
|
268
|
+
|
|
269
|
+
Every agent connects to http://host:port/mcp — one process, one ssh
|
|
270
|
+
tunnel, one set of local forward ports, so concurrent agents can never
|
|
271
|
+
collide on tunnel port binds the way N stdio processes do. Requests are
|
|
272
|
+
independent: each tool call builds its own DBManager/client (db_query),
|
|
273
|
+
so there is no shared-connection state between agents.
|
|
274
|
+
|
|
275
|
+
Trade-off vs stdio: shared fate. If this process dies, every agent loses
|
|
276
|
+
DB access until it restarts — run it under a supervisor (systemd --user
|
|
277
|
+
with Restart=always; see docs/setup.md). Binds localhost by default;
|
|
278
|
+
do not expose it to a network without an auth layer in front.
|
|
279
|
+
"""
|
|
280
|
+
mcp.settings.host = host
|
|
281
|
+
mcp.settings.port = port
|
|
282
|
+
mcp.run(transport="streamable-http")
|
|
283
|
+
|
|
284
|
+
|
|
266
285
|
if __name__ == "__main__":
|
|
267
286
|
# Bare `python server.py` (or `python server.py serve`) starts the MCP
|
|
268
287
|
# server — the common case for MCP client configs. Any other subcommand
|