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.
Files changed (33) hide show
  1. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/PKG-INFO +34 -14
  2. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/README.md +33 -12
  3. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/_version.py +1 -1
  4. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/cli.py +26 -1
  5. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/manager.py +34 -33
  6. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/wrappers.py +11 -14
  7. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/db_query.py +60 -66
  8. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/pyproject.toml +7 -2
  9. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/server.py +19 -0
  10. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/PKG-INFO +34 -14
  11. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/SOURCES.txt +6 -1
  12. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/requires.txt +0 -1
  13. sqlbuddy_nwi-0.4.0/tests/test_client_timeouts.py +103 -0
  14. sqlbuddy_nwi-0.4.0/tests/test_http_server.py +114 -0
  15. sqlbuddy_nwi-0.4.0/tests/test_query_rows.py +142 -0
  16. sqlbuddy_nwi-0.4.0/tests/test_tunnel.py +609 -0
  17. sqlbuddy_nwi-0.4.0/tests/test_version_sync.py +61 -0
  18. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tunnel.py +17 -2
  19. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/LICENSE +0 -0
  20. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/__init__.py +0 -0
  21. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/config_util.py +0 -0
  22. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/readonly.py +0 -0
  23. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/connections/runtime.py +0 -0
  24. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/db_security.py +0 -0
  25. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/init.py +0 -0
  26. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/setup.cfg +0 -0
  27. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/dependency_links.txt +0 -0
  28. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/entry_points.txt +0 -0
  29. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/sqlbuddy_nwi.egg-info/top_level.txt +0 -0
  30. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/test_vendor.py +0 -0
  31. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tests/test_get_info.py +0 -0
  32. {sqlbuddy_nwi-0.3.3 → sqlbuddy_nwi-0.4.0}/tests/test_parse_create_table.py +0 -0
  33. {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.3
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 [![PyPI](https://img.shields.io/pypi/v/sqlbuddy-nwi.svg)](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
70
+
70
71
  ```bash
71
- git clone <this-repo-url> sql-buddy && cd sql-buddy
72
- uv run server.py # installs deps into an isolated .venv, then serves (Ctrl+C to stop)
73
- uv run sql-buddy init # interactive wizard → writes connections/config/db.ini
74
- uv run sql-buddy doctor --connect # green-checks the venv, db.ini, tunnels, MCP client config
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
- uv run sql-buddy init
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
- uv run sql-buddy mcp add # Claude Code project → ./.mcp.json
144
- uv run sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
145
- uv run sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
146
- uv run sql-buddy mcp add --client trae --scope user # Trae user-global
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 `uv run sql-buddy mcp print`
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 [![PyPI](https://img.shields.io/pypi/v/sqlbuddy-nwi.svg)](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
42
+
41
43
  ```bash
42
- git clone <this-repo-url> sql-buddy && cd sql-buddy
43
- uv run server.py # installs deps into an isolated .venv, then serves (Ctrl+C to stop)
44
- uv run sql-buddy init # interactive wizard → writes connections/config/db.ini
45
- uv run sql-buddy doctor --connect # green-checks the venv, db.ini, tunnels, MCP client config
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
- uv run sql-buddy init
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
- uv run sql-buddy mcp add # Claude Code project → ./.mcp.json
115
- uv run sql-buddy mcp add --scope user # Claude Code user → ~/.claude.json
116
- uv run sql-buddy mcp add --client trae # Trae project → ./.trae/mcp.json
117
- uv run sql-buddy mcp add --client trae --scope user # Trae user-global
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 `uv run sql-buddy mcp print`
141
+ Restart your client. Preview the exact block first with `sql-buddy mcp print`
121
142
  (paths filled in):
122
143
 
123
144
  ```json
@@ -7,4 +7,4 @@ still report a version. `cli.get_version()` prefers installed metadata when
7
7
  available and falls back to this.
8
8
  """
9
9
 
10
- __version__ = "0.3.3"
10
+ __version__ = "0.4.0"
@@ -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
- from tunnel import get_manager as _get_tunnel_manager
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
- return Client(**self._resolve_conn_config(section, 9000))
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
- return MySQLConnectionWrapper(pymysql.connect(**self._resolve_conn_config(section, 3306)))
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 query_dataframe(self, sql: str) -> pd.DataFrame:
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
- return pd.DataFrame(rows)
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 query_dataframe(self, sql: str) -> pd.DataFrame:
77
+ def query_rows(self, sql: str):
72
78
  _filter_sql(sql)
73
- return self._wrapped.query_dataframe(sql)
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
- if hasattr(client, 'query_dataframe'):
141
- df = client.query_dataframe(sql)
142
- if df.empty:
143
- return json.dumps({"columns": [], "rows": [], "row_count": 0})
144
- rows = [_sanitize_row(r) for r in df.to_dict(orient='records')]
145
- return json.dumps({
146
- "columns": list(df.columns),
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
- if hasattr(client, 'query_dataframe'):
291
- result = client.query_dataframe(f"SHOW CREATE TABLE {tbl_name}")
292
- schema = result.to_string() if not result.empty else ""
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')`` dispatch: BOTH
327
- Locked wrappers define ``query_dataframe`` (MySQL natively, ClickHouse via
328
- the adapter in connections/wrappers.py), so that attribute check *always*
329
- picked the MySQL branch — even for ClickHouse, which then fabricated a
330
- fake PRIMARY index from ClickHouse's stub ``SHOW INDEX``. This reads the
331
- section's declared ``type`` (with the section-name prefix fallback), the
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
- result = client.query_dataframe(f"SELECT COUNT(*) as cnt FROM {tbl_name}")
415
- info['row_count'] = int(result['cnt'].iloc[0]) if not result.empty else 0
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
- idx_result = client.query_dataframe(f"SHOW INDEX FROM {tbl_name}")
420
- if not idx_result.empty:
421
- info['indexes'] = idx_result.to_dict('records')
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
- if hasattr(client, 'query_dataframe'):
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, 'query_dataframe'):
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
- result = client.query_dataframe(sql)
529
- if result.empty:
526
+ columns, rows = _fetch_rows(client, sql)
527
+ if not rows:
530
528
  return json.dumps({"tables": [], "count": 0})
531
- col = result.columns[0]
532
- tables = result[col].tolist()
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, 'query_dataframe'):
618
- df = client.query_dataframe(sql)
619
- if df is None or getattr(df, 'empty', True):
620
- return []
621
- out = []
622
- for row in df.itertuples(index=False, name=None):
623
- db_name = str(row[0]) if len(row) > 0 and row[0] is not None else ''
624
- tbl = str(row[1]) if len(row) > 1 and row[1] is not None else ''
625
- eng = str(row[2]) if len(row) > 2 and row[2] is not None else ''
626
- out.append((db_name, tbl, eng))
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, 'query_dataframe'):
761
- result = client.query_dataframe(explain_sql)
762
- if result.empty:
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.3"
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