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.
Files changed (33) hide show
  1. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/PKG-INFO +2 -3
  2. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/README.md +1 -1
  3. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/_version.py +1 -1
  4. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/cli.py +26 -1
  5. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/manager.py +15 -31
  6. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/wrappers.py +11 -14
  7. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/db_query.py +60 -66
  8. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/pyproject.toml +7 -2
  9. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/server.py +19 -0
  10. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/PKG-INFO +2 -3
  11. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/SOURCES.txt +5 -1
  12. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/requires.txt +0 -1
  13. sqlbuddy_nwi-0.4.1/tests/test_http_server.py +114 -0
  14. sqlbuddy_nwi-0.4.1/tests/test_query_rows.py +142 -0
  15. sqlbuddy_nwi-0.4.1/tests/test_tunnel.py +609 -0
  16. sqlbuddy_nwi-0.4.1/tests/test_version_sync.py +61 -0
  17. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tunnel.py +17 -2
  18. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/LICENSE +0 -0
  19. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/__init__.py +0 -0
  20. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/config_util.py +0 -0
  21. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/readonly.py +0 -0
  22. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/connections/runtime.py +0 -0
  23. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/db_security.py +0 -0
  24. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/init.py +0 -0
  25. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/setup.cfg +0 -0
  26. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/dependency_links.txt +0 -0
  27. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/entry_points.txt +0 -0
  28. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/sqlbuddy_nwi.egg-info/top_level.txt +0 -0
  29. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/test_vendor.py +0 -0
  30. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_client_timeouts.py +0 -0
  31. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_get_info.py +0 -0
  32. {sqlbuddy_nwi-0.3.4 → sqlbuddy_nwi-0.4.1}/tests/test_parse_create_table.py +0 -0
  33. {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.4
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 **0.3.3**, Python ≥ 3.11):
69
+ **Install** (current release [![PyPI](https://img.shields.io/pypi/v/sqlbuddy-nwi.svg)](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 **0.3.3**, Python ≥ 3.11):
41
+ **Install** (current release [![PyPI](https://img.shields.io/pypi/v/sqlbuddy-nwi.svg)](https://pypi.org/project/sqlbuddy-nwi/), Python ≥ 3.11):
42
42
 
43
43
  ```bash
44
44
  # From PyPI (recommended):
@@ -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.4"
10
+ __version__ = "0.4.1"
@@ -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]] = {}
@@ -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 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.4"
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.4
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 **0.3.3**, Python ≥ 3.11):
69
+ **Install** (current release [![PyPI](https://img.shields.io/pypi/v/sqlbuddy-nwi.svg)](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/test_sanitize_search.py
28
+ tests/test_query_rows.py
29
+ tests/test_sanitize_search.py
30
+ tests/test_tunnel.py
31
+ tests/test_version_sync.py
@@ -1,5 +1,4 @@
1
1
  mcp<2,>=1.0.0
2
2
  pymysql>=1.1.0
3
3
  clickhouse-driver>=0.2.6
4
- pandas>=2.0.0
5
4
  platformdirs>=4.0.0
@@ -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"]))