sqlbuddy-nwi 0.3.4__tar.gz → 0.4.1__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.4 → sqlbuddy_nwi-0.4.1}/PKG-INFO +2 -3
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/README.md +1 -1
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/_version.py +1 -1
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/cli.py +26 -1
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/manager.py +15 -31
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/wrappers.py +11 -14
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/db_query.py +60 -66
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/pyproject.toml +7 -2
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/server.py +19 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/PKG-INFO +2 -3
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/SOURCES.txt +5 -1
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/requires.txt +0 -1
- sqlbuddy_nwi-0.4.1/tests/test_http_server.py +114 -0
- sqlbuddy_nwi-0.4.1/tests/test_query_rows.py +142 -0
- sqlbuddy_nwi-0.4.1/tests/test_tunnel.py +609 -0
- sqlbuddy_nwi-0.4.1/tests/test_version_sync.py +61 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tunnel.py +17 -2
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/LICENSE +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/__init__.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/config_util.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/readonly.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/runtime.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/db_security.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/init.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/setup.cfg +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/dependency_links.txt +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/entry_points.txt +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/top_level.txt +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/test_vendor.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_client_timeouts.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_get_info.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_parse_create_table.py +0 -0
- {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/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.1
|
|
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,7 +66,7 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
|
|
|
67
66
|
|
|
68
67
|
## Quickstart
|
|
69
68
|
|
|
70
|
-
**Install** (current release
|
|
69
|
+
**Install** (current release [](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
|
|
71
70
|
|
|
72
71
|
```bash
|
|
73
72
|
# From PyPI (recommended):
|
|
@@ -38,7 +38,7 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
|
|
|
38
38
|
|
|
39
39
|
## Quickstart
|
|
40
40
|
|
|
41
|
-
**Install** (current release
|
|
41
|
+
**Install** (current release [](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
# From PyPI (recommended):
|
|
@@ -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]] = {}
|
|
@@ -168,35 +181,6 @@ class DBManager:
|
|
|
168
181
|
elif hasattr(client, "close"):
|
|
169
182
|
client.close()
|
|
170
183
|
|
|
171
|
-
@contextmanager
|
|
172
|
-
def session_scope(self, client_name: str = 'ck'):
|
|
173
|
-
client = getattr(self, client_name)
|
|
174
|
-
session_id = str(uuid.uuid4())
|
|
175
|
-
original_execute = client.execute
|
|
176
|
-
original_query_dataframe = getattr(client, 'query_dataframe', None)
|
|
177
|
-
|
|
178
|
-
def scoped_execute(query, params=None, settings=None, **kwargs):
|
|
179
|
-
if settings is None:
|
|
180
|
-
settings = {}
|
|
181
|
-
settings['session_id'] = session_id
|
|
182
|
-
return original_execute(query, params=params, settings=settings, **kwargs)
|
|
183
|
-
|
|
184
|
-
def scoped_query_dataframe(query, params=None, settings=None, **kwargs):
|
|
185
|
-
if settings is None:
|
|
186
|
-
settings = {}
|
|
187
|
-
settings['session_id'] = session_id
|
|
188
|
-
return original_query_dataframe(query, params=params, settings=settings, **kwargs)
|
|
189
|
-
|
|
190
|
-
class SessionClient:
|
|
191
|
-
def execute(self, *args, **kwargs):
|
|
192
|
-
return scoped_execute(*args, **kwargs)
|
|
193
|
-
def query_dataframe(self, *args, **kwargs):
|
|
194
|
-
if original_query_dataframe:
|
|
195
|
-
return scoped_query_dataframe(*args, **kwargs)
|
|
196
|
-
raise AttributeError("query_dataframe not available on this client")
|
|
197
|
-
|
|
198
|
-
yield SessionClient()
|
|
199
|
-
|
|
200
184
|
@contextmanager
|
|
201
185
|
def transaction_scope(self, connection: str):
|
|
202
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.1"
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlbuddy-nwi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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,7 +66,7 @@ SQL Buddy runs **locally** (a small Python program, not a cloud service). It
|
|
|
67
66
|
|
|
68
67
|
## Quickstart
|
|
69
68
|
|
|
70
|
-
**Install** (current release
|
|
69
|
+
**Install** (current release [](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
|
|
71
70
|
|
|
72
71
|
```bash
|
|
73
72
|
# From PyPI (recommended):
|
|
@@ -23,5 +23,9 @@ sqlbuddy_nwi.egg-info/requires.txt
|
|
|
23
23
|
sqlbuddy_nwi.egg-info/top_level.txt
|
|
24
24
|
tests/test_client_timeouts.py
|
|
25
25
|
tests/test_get_info.py
|
|
26
|
+
tests/test_http_server.py
|
|
26
27
|
tests/test_parse_create_table.py
|
|
27
|
-
tests/
|
|
28
|
+
tests/test_query_rows.py
|
|
29
|
+
tests/test_sanitize_search.py
|
|
30
|
+
tests/test_tunnel.py
|
|
31
|
+
tests/test_version_sync.py
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""End-to-end: the shared streamable-HTTP server serves N agents from one process.
|
|
2
|
+
|
|
3
|
+
Receipt (2026-11-13): stdio mode spawns one server.py per agent — ~87 MiB each
|
|
4
|
+
after the pandas removal, and each tries to bind the SAME tunnel ports, so
|
|
5
|
+
concurrent agents collide (ExitOnForwardFailure) and all but the first lose
|
|
6
|
+
DB access. `sql-buddy http` runs ONE process; this test spawns it for real and
|
|
7
|
+
drives TWO concurrent MCP clients (installed mcp client stack, real HTTP +
|
|
8
|
+
JSON-RPC), asserting both complete full round-trips while the server stays up.
|
|
9
|
+
|
|
10
|
+
No DB needed: check_connection on an unconfigured name returns structured
|
|
11
|
+
error JSON — proving the whole path (HTTP -> session -> tool -> db layer ->
|
|
12
|
+
JSON response) without touching a database.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _free_port() -> int:
|
|
30
|
+
s = socket.socket()
|
|
31
|
+
s.bind(("127.0.0.1", 0))
|
|
32
|
+
port = s.getsockname()[1]
|
|
33
|
+
s.close()
|
|
34
|
+
return port
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def http_url():
|
|
39
|
+
port = _free_port()
|
|
40
|
+
proc = subprocess.Popen(
|
|
41
|
+
[sys.executable, "server.py", "http", "--port", str(port)],
|
|
42
|
+
cwd=str(ROOT), stdin=subprocess.DEVNULL,
|
|
43
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
44
|
+
)
|
|
45
|
+
deadline = time.monotonic() + 20
|
|
46
|
+
while time.monotonic() < deadline:
|
|
47
|
+
if proc.poll() is not None:
|
|
48
|
+
pytest.fail(f"http server died at startup: {proc.stderr.read().decode()[:500]}")
|
|
49
|
+
try:
|
|
50
|
+
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
|
51
|
+
break
|
|
52
|
+
except OSError:
|
|
53
|
+
time.sleep(0.2)
|
|
54
|
+
else:
|
|
55
|
+
proc.kill()
|
|
56
|
+
pytest.fail("http server never opened its port")
|
|
57
|
+
yield f"http://127.0.0.1:{port}/mcp"
|
|
58
|
+
proc.terminate()
|
|
59
|
+
try:
|
|
60
|
+
proc.wait(timeout=10)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
proc.kill()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _roundtrip(url: str, out: dict, idx: int):
|
|
66
|
+
async def go():
|
|
67
|
+
import httpx
|
|
68
|
+
from mcp import ClientSession
|
|
69
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
70
|
+
|
|
71
|
+
def _no_env_proxies(**kwargs):
|
|
72
|
+
# Loopback must not be routed through a host proxy (a SOCKS
|
|
73
|
+
# ALL_PROXY without socksio installed kills the client).
|
|
74
|
+
return httpx.AsyncClient(trust_env=False, **kwargs)
|
|
75
|
+
|
|
76
|
+
async with streamablehttp_client(url, httpx_client_factory=_no_env_proxies) as (read, write, _):
|
|
77
|
+
async with ClientSession(read, write) as session:
|
|
78
|
+
await session.initialize()
|
|
79
|
+
tools = await session.list_tools()
|
|
80
|
+
res = await session.call_tool(
|
|
81
|
+
"check_connection", {"connection": "definitely-not-configured"})
|
|
82
|
+
payload = json.loads(res.content[0].text)
|
|
83
|
+
out[idx] = {"n_tools": len(tools.tools),
|
|
84
|
+
"payload_keys": sorted(payload.keys()),
|
|
85
|
+
"status": payload.get("status")}
|
|
86
|
+
asyncio.run(go())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_two_concurrent_clients_share_one_http_server(http_url):
|
|
90
|
+
out: dict = {}
|
|
91
|
+
threads = [threading.Thread(target=_roundtrip, args=(http_url, out, i))
|
|
92
|
+
for i in range(2)]
|
|
93
|
+
for t in threads:
|
|
94
|
+
t.start()
|
|
95
|
+
for t in threads:
|
|
96
|
+
t.join(timeout=60)
|
|
97
|
+
assert len(out) == 2, f"both clients must finish: {out}"
|
|
98
|
+
|
|
99
|
+
for idx, result in out.items():
|
|
100
|
+
assert result["n_tools"] >= 8, f"client {idx}: tool catalog came back thin"
|
|
101
|
+
# unconfigured connection -> structured error JSON, not a crash
|
|
102
|
+
assert "connection" in result["payload_keys"] and result["status"] == "error"
|
|
103
|
+
|
|
104
|
+
assert out[0]["n_tools"] == out[1]["n_tools"], "clients saw different catalogs"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_http_server_default_is_loopback(http_url):
|
|
108
|
+
"""The shared server must not bind a public interface by accident."""
|
|
109
|
+
host = http_url.split(":")[1].replace("//", "")
|
|
110
|
+
assert host in ("127.0.0.1", "localhost", "::1")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(pytest.main([__file__, "-v"]))
|