sqlbuddy-nwi 0.3.1__py3-none-any.whl
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.
- _version.py +10 -0
- cli.py +1164 -0
- connections/__init__.py +31 -0
- connections/config_util.py +159 -0
- connections/manager.py +205 -0
- connections/readonly.py +45 -0
- connections/runtime.py +51 -0
- connections/wrappers.py +105 -0
- db_query.py +735 -0
- db_security.py +146 -0
- init.py +528 -0
- server.py +275 -0
- sqlbuddy_nwi-0.3.1.dist-info/METADATA +705 -0
- sqlbuddy_nwi-0.3.1.dist-info/RECORD +20 -0
- sqlbuddy_nwi-0.3.1.dist-info/WHEEL +5 -0
- sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt +3 -0
- sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlbuddy_nwi-0.3.1.dist-info/top_level.txt +9 -0
- test_vendor.py +50 -0
- tunnel.py +345 -0
cli.py
ADDED
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
"""sql-buddy command-line interface.
|
|
2
|
+
|
|
3
|
+
Single dispatcher behind the `sql-buddy` console script AND the subcommand
|
|
4
|
+
entry for `python server.py <cmd>`.
|
|
5
|
+
|
|
6
|
+
Subcommands:
|
|
7
|
+
(none) | serve | stdio run the MCP server over stdio (normal mode)
|
|
8
|
+
version | --version | -V print version
|
|
9
|
+
doctor [--connect] validate install + db.ini (+ live pings if --connect)
|
|
10
|
+
update-check compare local checkout to the git remote
|
|
11
|
+
mcp print [--python] print the MCP client config block (paths filled in)
|
|
12
|
+
mcp add [--client claude|trae] [--scope project|user] [--python]
|
|
13
|
+
write the config block into a client's config file
|
|
14
|
+
init run the interactive setup wizard
|
|
15
|
+
help | --help | -h show usage
|
|
16
|
+
|
|
17
|
+
Bare `sql-buddy` (or `python server.py` with no subcommand) starts the MCP
|
|
18
|
+
server, so existing MCP client configs keep working unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
|
|
29
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
30
|
+
|
|
31
|
+
# The Python version floor declared in pyproject.toml (requires-python).
|
|
32
|
+
_MIN_PY = (3, 11)
|
|
33
|
+
|
|
34
|
+
# PyPI distribution name. Differs from the command (`sql-buddy`) and the MCP
|
|
35
|
+
# server name because `sql-buddy` was already taken on PyPI by an unrelated
|
|
36
|
+
# 2020 project — see docs/adr/0004-published-as-sqlbuddy-nwi.md. uvx therefore
|
|
37
|
+
# needs `--from sqlbuddy-nwi sql-buddy` (package name != command name).
|
|
38
|
+
_DIST_NAME = "sqlbuddy-nwi"
|
|
39
|
+
|
|
40
|
+
# Runtime dependencies doctor verifies are importable. These mirror
|
|
41
|
+
# pyproject.toml [project.dependencies] (plus the system `ssh` binary for
|
|
42
|
+
# Auto-mode tunnels, checked separately).
|
|
43
|
+
_RUNTIME_DEPS = [
|
|
44
|
+
("mcp", "mcp.server.fastmcp"),
|
|
45
|
+
("pymysql", None),
|
|
46
|
+
("clickhouse_driver", None),
|
|
47
|
+
("pandas", None),
|
|
48
|
+
("platformdirs", None),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# version
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def get_version() -> str:
|
|
57
|
+
"""Installed dist version if available, else the bundled literal."""
|
|
58
|
+
try:
|
|
59
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
60
|
+
try:
|
|
61
|
+
return version(_DIST_NAME)
|
|
62
|
+
except PackageNotFoundError:
|
|
63
|
+
pass
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
try:
|
|
67
|
+
from _version import __version__
|
|
68
|
+
return __version__
|
|
69
|
+
except Exception:
|
|
70
|
+
return "0.0.0+unknown"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_version() -> int:
|
|
74
|
+
print(get_version())
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# doctor
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _check_python() -> tuple[str, bool]:
|
|
83
|
+
ok = sys.version_info >= _MIN_PY
|
|
84
|
+
return (f"Python {sys.version_info.major}.{sys.version_info.minor}"
|
|
85
|
+
f".{sys.version_info.micro} (need >={_MIN_PY[0]}.{_MIN_PY[1]})", ok)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_dep(dist: str, mod: str | None) -> tuple[str, bool]:
|
|
89
|
+
import importlib
|
|
90
|
+
target = mod or dist
|
|
91
|
+
try:
|
|
92
|
+
importlib.import_module(target)
|
|
93
|
+
return f"dependency importable: {target}", True
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return f"dependency MISSING: {target} ({e})", False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _has_tunnel(cfg) -> bool:
|
|
99
|
+
"""True if any connection declares a self-managed SSH tunnel."""
|
|
100
|
+
if cfg is None:
|
|
101
|
+
return False
|
|
102
|
+
for name in _connection_names(cfg):
|
|
103
|
+
val = cfg.get(name, "tunnel", fallback="").strip()
|
|
104
|
+
if val:
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_ssh(needed: bool) -> tuple[str, str, str]:
|
|
110
|
+
"""Return (label, severity, detail). severity in {'ok','warn','fail'}.
|
|
111
|
+
|
|
112
|
+
`needed` says whether ANY configured connection actually uses a self-managed
|
|
113
|
+
tunnel. ssh is only required in that case, so a missing binary is a hard
|
|
114
|
+
failure only when a tunnel is configured; otherwise it's a non-blocking warn
|
|
115
|
+
(e.g. an External/direct-only setup never needs ssh).
|
|
116
|
+
"""
|
|
117
|
+
path = shutil.which("ssh")
|
|
118
|
+
if path:
|
|
119
|
+
return "ssh binary", "ok", f"on PATH: {path}"
|
|
120
|
+
if needed:
|
|
121
|
+
hint = (
|
|
122
|
+
"install OpenSSH Client (Windows Settings → Apps → Optional features)"
|
|
123
|
+
if sys.platform == "win32"
|
|
124
|
+
else "install an OpenSSH client (e.g. openssh-client)"
|
|
125
|
+
)
|
|
126
|
+
return ("ssh binary", "fail",
|
|
127
|
+
"NOT on PATH but a connection uses a self-managed tunnel "
|
|
128
|
+
f"(Auto mode); {hint}")
|
|
129
|
+
return ("ssh binary", "warn",
|
|
130
|
+
"not on PATH (only needed for Auto-mode tunnels — fine for "
|
|
131
|
+
"External/direct-only setups)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_db_ini():
|
|
135
|
+
"""Return (configparser, error_or_None).
|
|
136
|
+
|
|
137
|
+
Uses the shared `connections.config_util.read_config` reader (UTF-8-BOM /
|
|
138
|
+
UTF-8 / GBK, same as the runtime) so doctor agrees with what the server
|
|
139
|
+
will actually load. Never echoes the raw parse exception — ConfigParser
|
|
140
|
+
errors embed the offending source line, which on db.ini can be a password.
|
|
141
|
+
"""
|
|
142
|
+
from connections.config_util import config_path, read_config
|
|
143
|
+
cfg_file = config_path()
|
|
144
|
+
if not os.path.exists(cfg_file):
|
|
145
|
+
return None, f"db.ini not found at {cfg_file} (run: sql-buddy init)"
|
|
146
|
+
try:
|
|
147
|
+
return read_config(cfg_file), None
|
|
148
|
+
except Exception:
|
|
149
|
+
return None, ("db.ini failed to parse (syntax/encoding error; the "
|
|
150
|
+
"offending line is hidden to avoid leaking secrets)")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_ssh_section(name: str) -> bool:
|
|
154
|
+
"""Match runtime/tunnel.py: section names are matched case-insensitively."""
|
|
155
|
+
return name.lower().startswith("ssh:")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _connection_names(cfg) -> list[str]:
|
|
159
|
+
return [s for s in cfg.sections() if not _is_ssh_section(s)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ssh_block_keys(cfg) -> set[str]:
|
|
163
|
+
"""Lowercased bastion names from every [ssh:*]/[SSH:*] section."""
|
|
164
|
+
keys = set()
|
|
165
|
+
for s in cfg.sections():
|
|
166
|
+
if _is_ssh_section(s):
|
|
167
|
+
keys.add(s.split(":", 1)[1].lower())
|
|
168
|
+
return keys
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _tunnel_block_name(raw: str) -> str:
|
|
172
|
+
"""Extract bastion name from tunnel=ssh:prod / tunnel=SSH:prod / tunnel=prod."""
|
|
173
|
+
val = (raw or "").strip()
|
|
174
|
+
if val.lower().startswith("ssh:"):
|
|
175
|
+
return val.split(":", 1)[1].lower()
|
|
176
|
+
return val.lower()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_VALID_TYPES = frozenset({"clickhouse", "mysql", "mariadb"})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _port_ok(raw: str) -> bool:
|
|
183
|
+
"""True if raw is an integer port in 1..65535 (runtime does int(port))."""
|
|
184
|
+
try:
|
|
185
|
+
p = int(str(raw).strip())
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
return False
|
|
188
|
+
return 1 <= p <= 65535
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _resolve_type_like_runtime(cfg, name: str) -> tuple[str | None, str | None]:
|
|
192
|
+
"""Mirror connections.manager.connection_type (without importing manager).
|
|
193
|
+
|
|
194
|
+
Returns (ctype, error). error is set when runtime would raise ValueError
|
|
195
|
+
(no explicit type and name prefix doesn't match ck*/db*/mysql*).
|
|
196
|
+
"""
|
|
197
|
+
explicit = cfg.get(name, "type", fallback="").strip().lower()
|
|
198
|
+
if explicit:
|
|
199
|
+
if explicit not in _VALID_TYPES:
|
|
200
|
+
return None, f"unknown type '{explicit}' (want clickhouse|mysql|mariadb)"
|
|
201
|
+
return explicit, None
|
|
202
|
+
low = name.lower()
|
|
203
|
+
if low.startswith("ck"):
|
|
204
|
+
return "clickhouse", None
|
|
205
|
+
if low.startswith("db") or low.startswith("mysql"):
|
|
206
|
+
return "mysql", None
|
|
207
|
+
return None, "missing type (set type=clickhouse|mysql, or name the section ck*/db*/mysql*)"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_config(cfg) -> list[tuple[str, bool, str]]:
|
|
211
|
+
"""Static validation of db.ini shape. Returns (label, ok, detail) rows.
|
|
212
|
+
|
|
213
|
+
Mirrors runtime rules: connection_type() resolution + int(port) conversion.
|
|
214
|
+
SSH section/tunnel prefixes are matched case-insensitively (like tunnel.py).
|
|
215
|
+
"""
|
|
216
|
+
rows: list[tuple[str, bool, str]] = []
|
|
217
|
+
conns = _connection_names(cfg)
|
|
218
|
+
if not conns:
|
|
219
|
+
rows.append(("db.ini: no connections defined", False,
|
|
220
|
+
"add at least one [section] (see db.ini.example)"))
|
|
221
|
+
ssh_sections = [s for s in cfg.sections() if _is_ssh_section(s)]
|
|
222
|
+
known_blocks = _ssh_block_keys(cfg)
|
|
223
|
+
for name in conns:
|
|
224
|
+
tunneled = cfg.has_option(name, "tunnel") and cfg.get(name, "tunnel", fallback="").strip()
|
|
225
|
+
issues = []
|
|
226
|
+
ctype, type_err = _resolve_type_like_runtime(cfg, name)
|
|
227
|
+
if type_err:
|
|
228
|
+
issues.append(type_err)
|
|
229
|
+
if tunneled:
|
|
230
|
+
block = _tunnel_block_name(tunneled)
|
|
231
|
+
if block not in known_blocks:
|
|
232
|
+
issues.append(f"tunnel references missing [ssh:{block}]")
|
|
233
|
+
for req in ("remote_host", "remote_port", "local_port"):
|
|
234
|
+
if not cfg.has_option(name, req):
|
|
235
|
+
issues.append(f"missing {req}")
|
|
236
|
+
for pkey in ("remote_port", "local_port"):
|
|
237
|
+
if cfg.has_option(name, pkey) and not _port_ok(cfg.get(name, pkey)):
|
|
238
|
+
issues.append(f"{pkey} must be integer 1..65535")
|
|
239
|
+
else:
|
|
240
|
+
for req in ("host", "port"):
|
|
241
|
+
if not cfg.has_option(name, req):
|
|
242
|
+
issues.append(f"missing {req}")
|
|
243
|
+
if cfg.has_option(name, "port") and not _port_ok(cfg.get(name, "port")):
|
|
244
|
+
issues.append("port must be integer 1..65535")
|
|
245
|
+
if not cfg.has_option(name, "user"):
|
|
246
|
+
issues.append("missing user")
|
|
247
|
+
detail = ", ".join(issues) if issues else (
|
|
248
|
+
f"{ctype or '?'}" + (f", tunnel={tunneled}" if tunneled else ", direct")
|
|
249
|
+
)
|
|
250
|
+
rows.append((f"connection [{name}]", not issues, detail))
|
|
251
|
+
for block in ssh_sections:
|
|
252
|
+
for req in ("host", "user", "key"):
|
|
253
|
+
if not cfg.has_option(block, req):
|
|
254
|
+
rows.append((f"bastion [{block}]", False, f"missing {req}"))
|
|
255
|
+
if cfg.has_option(block, "port") and not _port_ok(cfg.get(block, "port")):
|
|
256
|
+
rows.append((f"bastion [{block}]", False, "port must be integer 1..65535"))
|
|
257
|
+
return rows
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _trae_user_mcp_candidates() -> list[str]:
|
|
261
|
+
"""Likely Trae user-scope mcp.json locations (first existing wins for write).
|
|
262
|
+
|
|
263
|
+
Project-scope is always `<cwd>/.trae/mcp.json` (official Trae docs).
|
|
264
|
+
User-scope is not fully documented; Trae is VS Code-based so we probe the
|
|
265
|
+
standard User/ folder plus a ~/.trae fallback used by some installs.
|
|
266
|
+
"""
|
|
267
|
+
home = os.path.expanduser("~")
|
|
268
|
+
names = ("Trae", "Trae CN", "Trae-CN", "trae")
|
|
269
|
+
out: list[str] = []
|
|
270
|
+
if sys.platform == "win32":
|
|
271
|
+
appdata = os.environ.get("APPDATA") or os.path.join(
|
|
272
|
+
home, "AppData", "Roaming")
|
|
273
|
+
for name in names:
|
|
274
|
+
out.append(os.path.join(appdata, name, "User", "mcp.json"))
|
|
275
|
+
elif sys.platform == "darwin":
|
|
276
|
+
base = os.path.join(home, "Library", "Application Support")
|
|
277
|
+
for name in names:
|
|
278
|
+
out.append(os.path.join(base, name, "User", "mcp.json"))
|
|
279
|
+
else:
|
|
280
|
+
for name in names:
|
|
281
|
+
out.append(os.path.join(home, ".config", name, "User", "mcp.json"))
|
|
282
|
+
out.append(os.path.join(home, ".trae", "mcp.json"))
|
|
283
|
+
seen: set[str] = set()
|
|
284
|
+
uniq: list[str] = []
|
|
285
|
+
for p in out:
|
|
286
|
+
key = os.path.normcase(os.path.normpath(p))
|
|
287
|
+
if key not in seen:
|
|
288
|
+
seen.add(key)
|
|
289
|
+
uniq.append(p)
|
|
290
|
+
return uniq
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _mcp_candidate_files() -> list[str]:
|
|
294
|
+
home = os.path.expanduser("~")
|
|
295
|
+
cands = [
|
|
296
|
+
os.path.join(os.getcwd(), ".mcp.json"),
|
|
297
|
+
os.path.join(os.getcwd(), ".trae", "mcp.json"),
|
|
298
|
+
os.path.join(os.getcwd(), ".claude", "settings.json"),
|
|
299
|
+
os.path.join(home, ".claude.json"),
|
|
300
|
+
]
|
|
301
|
+
cands.extend(_trae_user_mcp_candidates())
|
|
302
|
+
if sys.platform == "darwin":
|
|
303
|
+
cands.append(os.path.join(home, "Library", "Application Support",
|
|
304
|
+
"Claude", "claude_desktop_config.json"))
|
|
305
|
+
if sys.platform == "win32":
|
|
306
|
+
appdata = os.environ.get("APPDATA") or os.environ.get("AppData")
|
|
307
|
+
if appdata:
|
|
308
|
+
cands.append(os.path.join(appdata, "Claude",
|
|
309
|
+
"claude_desktop_config.json"))
|
|
310
|
+
seen: set[str] = set()
|
|
311
|
+
uniq: list[str] = []
|
|
312
|
+
for p in cands:
|
|
313
|
+
key = os.path.normcase(os.path.normpath(p))
|
|
314
|
+
if key not in seen:
|
|
315
|
+
seen.add(key)
|
|
316
|
+
uniq.append(p)
|
|
317
|
+
return uniq
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_trae_config_path(path: str) -> bool:
|
|
321
|
+
"""True if `path` is a Trae MCP config (project .trae/mcp.json or User/mcp.json)."""
|
|
322
|
+
norm = path.replace("\\", "/").lower()
|
|
323
|
+
if norm.endswith("/.trae/mcp.json") or norm.endswith("/.trae\\mcp.json"):
|
|
324
|
+
return True
|
|
325
|
+
if "/.trae/" in norm and norm.endswith("mcp.json"):
|
|
326
|
+
return True
|
|
327
|
+
# User-scope: …/Trae/User/mcp.json or …/Trae CN/User/mcp.json
|
|
328
|
+
if norm.endswith("/user/mcp.json") and "/trae" in norm:
|
|
329
|
+
return True
|
|
330
|
+
if norm.endswith("/.trae/mcp.json"):
|
|
331
|
+
return True
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _validate_mcp_entry(entry: dict, *, trae: bool = False) -> tuple[bool, str]:
|
|
336
|
+
"""Check one mcpServers entry resolves: command on PATH/exists AND every
|
|
337
|
+
absolute path in its args is present. Returns (ok, detail).
|
|
338
|
+
|
|
339
|
+
Every non-absolute command is checked with shutil.which (not an allowlist) —
|
|
340
|
+
a bare name like `sql-buddy` or a typo must fail, not silently "resolve".
|
|
341
|
+
Absolute / relative path commands are checked on the filesystem.
|
|
342
|
+
|
|
343
|
+
When `trae=True`, a command field containing spaces is a hard failure —
|
|
344
|
+
Trae's host rejects those configs (see docs.trae.ai add-mcp-servers).
|
|
345
|
+
"""
|
|
346
|
+
if not isinstance(entry, dict):
|
|
347
|
+
return False, f"entry must be an object, got {type(entry).__name__}"
|
|
348
|
+
cmd = entry.get("command")
|
|
349
|
+
args = entry.get("args", [])
|
|
350
|
+
if args is None:
|
|
351
|
+
args = []
|
|
352
|
+
if not isinstance(args, list):
|
|
353
|
+
return False, f"'args' must be a list, got {type(args).__name__}"
|
|
354
|
+
if not cmd or not isinstance(cmd, str):
|
|
355
|
+
return False, "no 'command' set"
|
|
356
|
+
# Trae: command must not contain spaces (host parse rule).
|
|
357
|
+
if trae and " " in cmd:
|
|
358
|
+
return False, (
|
|
359
|
+
f"command path contains spaces (Trae rejects this): {cmd} — "
|
|
360
|
+
"re-run: sql-buddy mcp add --client trae after installing uv/python "
|
|
361
|
+
"on a path without spaces"
|
|
362
|
+
)
|
|
363
|
+
if os.path.isabs(cmd) or os.sep in cmd or (os.altsep and os.altsep in cmd):
|
|
364
|
+
# Absolute or relative filesystem path — must be a regular file.
|
|
365
|
+
# On Windows, X_OK is not meaningful for .exe resolution (PATHEXT).
|
|
366
|
+
if not os.path.isfile(cmd):
|
|
367
|
+
return False, f"command path MISSING or not a file: {cmd}"
|
|
368
|
+
# Unix: require +x. Windows: X_OK is not meaningful (PATHEXT); skip.
|
|
369
|
+
if sys.platform != "win32" and not os.access(cmd, os.X_OK):
|
|
370
|
+
return False, f"command path is not executable: {cmd}"
|
|
371
|
+
else:
|
|
372
|
+
# Bare executable name — must be on PATH
|
|
373
|
+
if not shutil.which(cmd):
|
|
374
|
+
return False, f"command '{cmd}' not found on PATH"
|
|
375
|
+
missing = [a for a in args
|
|
376
|
+
if isinstance(a, str) and os.path.isabs(a) and not os.path.exists(a)]
|
|
377
|
+
if missing:
|
|
378
|
+
return False, f"arg path(s) MISSING: {', '.join(missing)}"
|
|
379
|
+
return True, f"command '{cmd}' resolves"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_mcp_config() -> list[tuple[str, bool, str]]:
|
|
383
|
+
rows: list[tuple[str, bool, str]] = []
|
|
384
|
+
found_any = False
|
|
385
|
+
for path in _mcp_candidate_files():
|
|
386
|
+
if not os.path.exists(path):
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
# utf-8-sig: Trae / Windows editors may write a BOM.
|
|
390
|
+
data = _read_json_file(path)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
rows.append((f"MCP config {path}", False, f"unreadable: {e}"))
|
|
393
|
+
continue
|
|
394
|
+
if not isinstance(data, dict):
|
|
395
|
+
rows.append((f"MCP config {path}", False, "top-level JSON is not an object"))
|
|
396
|
+
continue
|
|
397
|
+
servers = data.get("mcpServers", {})
|
|
398
|
+
if servers is None:
|
|
399
|
+
servers = {}
|
|
400
|
+
if not isinstance(servers, dict):
|
|
401
|
+
rows.append((f"MCP config {path}", False,
|
|
402
|
+
f"'mcpServers' must be an object, got {type(servers).__name__}"))
|
|
403
|
+
continue
|
|
404
|
+
trae = _is_trae_config_path(path)
|
|
405
|
+
if "sql-buddy" in servers:
|
|
406
|
+
found_any = True
|
|
407
|
+
ent = servers["sql-buddy"]
|
|
408
|
+
if not isinstance(ent, dict):
|
|
409
|
+
rows.append((f"MCP config {path} [sql-buddy]", False,
|
|
410
|
+
f"entry must be an object, got {type(ent).__name__}"))
|
|
411
|
+
else:
|
|
412
|
+
ok, detail = _validate_mcp_entry(ent, trae=trae)
|
|
413
|
+
rows.append((f"MCP config {path} [sql-buddy]", ok, detail))
|
|
414
|
+
else:
|
|
415
|
+
# Only treat an entry as sql-buddy if it is SQL-Buddy-specific —
|
|
416
|
+
# bare "server.py" is too generic (any Python MCP server).
|
|
417
|
+
for name, ent in servers.items():
|
|
418
|
+
if not isinstance(ent, dict):
|
|
419
|
+
continue
|
|
420
|
+
if not _looks_like_sql_buddy_entry(name, ent):
|
|
421
|
+
continue
|
|
422
|
+
found_any = True
|
|
423
|
+
ok, detail = _validate_mcp_entry(ent, trae=trae)
|
|
424
|
+
rows.append((f"MCP config {path} [{name}]", ok, detail))
|
|
425
|
+
if not found_any:
|
|
426
|
+
rows.append(("MCP client config", False,
|
|
427
|
+
"sql-buddy not registered with any detected client "
|
|
428
|
+
"(run: sql-buddy mcp add)"))
|
|
429
|
+
return rows
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _looks_like_sql_buddy_entry(name: str, ent: dict) -> bool:
|
|
433
|
+
"""True only for entries that clearly point at this project."""
|
|
434
|
+
if name.lower() in ("sql-buddy", "sql_buddy", "sqlbuddy"):
|
|
435
|
+
return True
|
|
436
|
+
blob = json.dumps(ent).lower()
|
|
437
|
+
if "sql-buddy" in blob or "sql_buddy" in blob:
|
|
438
|
+
return True
|
|
439
|
+
# resolved path into *this* checkout's server.py
|
|
440
|
+
here = _HERE.lower()
|
|
441
|
+
args = ent.get("args") or []
|
|
442
|
+
if isinstance(args, list):
|
|
443
|
+
for a in args:
|
|
444
|
+
if isinstance(a, str) and here in a.lower() and a.lower().endswith("server.py"):
|
|
445
|
+
return True
|
|
446
|
+
cmd = ent.get("command")
|
|
447
|
+
if isinstance(cmd, str) and here in cmd.lower() and "sql-buddy" in cmd.lower():
|
|
448
|
+
return True
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _sanitize_detail(text: str) -> str:
|
|
453
|
+
"""Redact known secrets from doctor detail lines (best-effort)."""
|
|
454
|
+
if not text:
|
|
455
|
+
return text
|
|
456
|
+
try:
|
|
457
|
+
import db_security
|
|
458
|
+
db_security.install()
|
|
459
|
+
return db_security.sanitize_text(text)
|
|
460
|
+
except Exception:
|
|
461
|
+
return _redact_secrets(text)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _live_hint(msg: str) -> str:
|
|
465
|
+
"""Actionable hint for a live-Connection failure. Mirrors server._hint_for
|
|
466
|
+
without coupling doctor to the MCP runtime. Always sanitized."""
|
|
467
|
+
msg = _sanitize_detail(msg)
|
|
468
|
+
low = msg.lower()
|
|
469
|
+
if any(k in low for k in ("refused", "timed out", "timeout", "tunnel",
|
|
470
|
+
"connect", "socket", " eof", "closed")):
|
|
471
|
+
return (f"{msg} | hint: transport/tunnel error — in External mode the "
|
|
472
|
+
"user's tunnel may be down; in Auto mode check remote_host/"
|
|
473
|
+
"remote_port (the DB as seen from the bastion)")
|
|
474
|
+
if any(k in low for k in ("access denied", "authentication", "auth",
|
|
475
|
+
"password", "login")):
|
|
476
|
+
return f"{msg} | hint: DB auth failed — check user/password in db.ini"
|
|
477
|
+
if "no section" in low or "unknown connection" in low:
|
|
478
|
+
return (f"{msg} | hint: unknown connection — run sql-buddy doctor to "
|
|
479
|
+
"list configured names")
|
|
480
|
+
return msg
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _check_connection_live(name: str) -> tuple[str, str, str]:
|
|
484
|
+
"""Ping one Connection (SELECT 1). Returns (label, severity, detail)."""
|
|
485
|
+
try:
|
|
486
|
+
# Match server startup: load .env + install secret filters before any
|
|
487
|
+
# db_query / connections import so $env: refs in db.ini resolve and
|
|
488
|
+
# exception text is redacted.
|
|
489
|
+
import db_security
|
|
490
|
+
db_security.install()
|
|
491
|
+
import db_query
|
|
492
|
+
raw = db_query.check_connection_query(name)
|
|
493
|
+
res = json.loads(raw)
|
|
494
|
+
if res.get("status") == "ok":
|
|
495
|
+
return (f"connection [{name}] reachable", "ok",
|
|
496
|
+
f"{res.get('latency_ms')} ms")
|
|
497
|
+
err = _sanitize_detail(str(res.get("error") or res))
|
|
498
|
+
return (f"connection [{name}] error", "fail", err)
|
|
499
|
+
except Exception as e:
|
|
500
|
+
return (f"connection [{name}] error", "fail", _live_hint(str(e)))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def doctor(connect: bool = False) -> int:
|
|
504
|
+
"""Validate the install. Returns process exit code (0 unless something FAILs;
|
|
505
|
+
WARNs never fail)."""
|
|
506
|
+
print(f"sql-buddy {get_version()} doctor\n")
|
|
507
|
+
fails = 0
|
|
508
|
+
warns = 0
|
|
509
|
+
|
|
510
|
+
def emit(label, severity, detail=""):
|
|
511
|
+
nonlocal fails, warns
|
|
512
|
+
if severity == "ok":
|
|
513
|
+
mark = "OK "
|
|
514
|
+
elif severity == "warn":
|
|
515
|
+
mark = "WARN"
|
|
516
|
+
warns += 1
|
|
517
|
+
else:
|
|
518
|
+
mark = "FAIL"
|
|
519
|
+
fails += 1
|
|
520
|
+
line = f" [{mark}] {label}"
|
|
521
|
+
if detail:
|
|
522
|
+
line += f" ({detail})"
|
|
523
|
+
print(line)
|
|
524
|
+
|
|
525
|
+
# Parse db.ini once, up front, so the ssh check can be conditional on
|
|
526
|
+
# whether any connection actually uses a self-managed tunnel.
|
|
527
|
+
cfg, err = _parse_db_ini()
|
|
528
|
+
|
|
529
|
+
# 1. runtime environment
|
|
530
|
+
print("== runtime ==")
|
|
531
|
+
lbl, ok = _check_python()
|
|
532
|
+
emit(lbl, "ok" if ok else "fail")
|
|
533
|
+
emit(*_check_ssh(needed=_has_tunnel(cfg)))
|
|
534
|
+
for dist, mod in _RUNTIME_DEPS:
|
|
535
|
+
d_label, d_ok = _check_dep(dist, mod)
|
|
536
|
+
emit(d_label, "ok" if d_ok else "fail")
|
|
537
|
+
|
|
538
|
+
# 2. config
|
|
539
|
+
print("\n== db.ini ==")
|
|
540
|
+
if err:
|
|
541
|
+
emit("db.ini", "fail", err)
|
|
542
|
+
cfg = None
|
|
543
|
+
else:
|
|
544
|
+
conns = _connection_names(cfg)
|
|
545
|
+
from connections.config_util import config_path as _config_path
|
|
546
|
+
emit(f"db.ini parsed ({len(conns)} connection(s))", "ok", _config_path())
|
|
547
|
+
for label, ok, detail in _check_config(cfg):
|
|
548
|
+
emit(label, "ok" if ok else "fail", detail)
|
|
549
|
+
|
|
550
|
+
# 3. mcp client config
|
|
551
|
+
print("\n== MCP client config ==")
|
|
552
|
+
for label, ok, detail in _check_mcp_config():
|
|
553
|
+
emit(label, "ok" if ok else "fail", detail)
|
|
554
|
+
|
|
555
|
+
# 4. live Connection pings (opt-in — may open tunnels / hit the network)
|
|
556
|
+
if connect and cfg is not None:
|
|
557
|
+
print("\n== live connections (SELECT 1) ==")
|
|
558
|
+
for name in _connection_names(cfg):
|
|
559
|
+
emit(*_check_connection_live(name))
|
|
560
|
+
elif connect and cfg is None:
|
|
561
|
+
print("\n== live connections skipped (no db.ini) ==")
|
|
562
|
+
|
|
563
|
+
summary = ", ".join([f"{fails} failed", f"{warns} warning(s)"])
|
|
564
|
+
print(f"\n{'All checks passed.' if fails == 0 and warns == 0 else summary}")
|
|
565
|
+
return 1 if fails else 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# update-check
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
def _redact_secrets(text: str) -> str:
|
|
573
|
+
"""Strip credential-bearing URL userinfo (and similar) from diagnostic text.
|
|
574
|
+
|
|
575
|
+
Git stderr often embeds the remote URL; if the remote is an HTTPS URL with
|
|
576
|
+
an embedded token (`https://user:TOKEN@host/...`), echoing raw stderr would
|
|
577
|
+
leak it. Replace userinfo with `***`.
|
|
578
|
+
"""
|
|
579
|
+
if not text:
|
|
580
|
+
return text
|
|
581
|
+
import re
|
|
582
|
+
# https://user:pass@host or https://token@host
|
|
583
|
+
text = re.sub(r"(https?://)([^/@\s]+)@", r"\1***@", text)
|
|
584
|
+
# ssh-style with embedded password is rare; still strip user:pass@host
|
|
585
|
+
text = re.sub(r"(\b)([^:\s/]+):([^@\s/]+)@", r"\1***@", text)
|
|
586
|
+
return text
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _git(args: list[str], cwd: str = _HERE) -> tuple[int, str, str]:
|
|
590
|
+
try:
|
|
591
|
+
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
592
|
+
return p.returncode, p.stdout.strip(), _redact_secrets(p.stderr.strip())
|
|
593
|
+
except FileNotFoundError:
|
|
594
|
+
return 127, "", "git not installed"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def update_check() -> int:
|
|
598
|
+
"""Compare the local checkout HEAD to the upstream tracking branch tip."""
|
|
599
|
+
rc, local, _ = _git(["rev-parse", "HEAD"])
|
|
600
|
+
if rc != 0:
|
|
601
|
+
print("Not a git checkout (or git unavailable) — cannot check for updates.")
|
|
602
|
+
print("If you installed via a vendored bundle, re-download the latest "
|
|
603
|
+
"release to update.")
|
|
604
|
+
return 0
|
|
605
|
+
print(f"local : {local[:12]} sql-buddy {get_version()}")
|
|
606
|
+
|
|
607
|
+
remote_name, branch_name = "origin", "master"
|
|
608
|
+
rc, upstream_ref, _ = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
|
609
|
+
if rc != 0:
|
|
610
|
+
# No upstream configured. Try origin/<current-branch> as a best effort.
|
|
611
|
+
rc2, branch, _ = _git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
612
|
+
branch_name = branch or "master"
|
|
613
|
+
remote_name = "origin"
|
|
614
|
+
upstream_label = f"origin/{branch_name}"
|
|
615
|
+
else:
|
|
616
|
+
upstream_label = upstream_ref
|
|
617
|
+
parts = upstream_ref.split("/", 1)
|
|
618
|
+
if len(parts) == 2:
|
|
619
|
+
remote_name, branch_name = parts
|
|
620
|
+
else:
|
|
621
|
+
remote_name, branch_name = "origin", parts[0]
|
|
622
|
+
|
|
623
|
+
# Never print raw git stderr: it can embed credential-bearing remote URLs
|
|
624
|
+
# (https://user:TOKEN@host/...) even after partial redaction.
|
|
625
|
+
rc, remote, _err = _git(["ls-remote", remote_name, branch_name])
|
|
626
|
+
if rc != 0:
|
|
627
|
+
print(f"Could not reach '{upstream_label}' (offline or auth failed). "
|
|
628
|
+
"Skipping remote comparison. Try: git fetch")
|
|
629
|
+
return 0
|
|
630
|
+
remote_sha = (remote.split()[0] if remote else "").strip()
|
|
631
|
+
|
|
632
|
+
print(f"remote: {remote_sha[:12] if remote_sha else '(none)'} {upstream_label}")
|
|
633
|
+
if not remote_sha:
|
|
634
|
+
print("Remote ref not found. Check your network or the branch name.")
|
|
635
|
+
return 0
|
|
636
|
+
if remote_sha == local:
|
|
637
|
+
print("Up to date.")
|
|
638
|
+
return 0
|
|
639
|
+
|
|
640
|
+
# ls-remote only returns a SHA — the object may not be in the local store.
|
|
641
|
+
# merge-base needs the object; fetch the tip if missing, otherwise ancestry
|
|
642
|
+
# checks exit 128 and we'd mis-report "diverged".
|
|
643
|
+
rc_has, _, _ = _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"])
|
|
644
|
+
if rc_has != 0:
|
|
645
|
+
rc_f, _, _err_f = _git(["fetch", "--quiet", remote_name, branch_name])
|
|
646
|
+
if rc_f != 0:
|
|
647
|
+
print(f"Remote tip {remote_sha[:12]} is not in local history and "
|
|
648
|
+
f"fetch failed. Run: git fetch")
|
|
649
|
+
return 0
|
|
650
|
+
# re-check after fetch
|
|
651
|
+
rc_has2, _, _ = _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"])
|
|
652
|
+
if rc_has2 != 0:
|
|
653
|
+
print(f"Fetched {upstream_label} but still missing object "
|
|
654
|
+
f"{remote_sha[:12]}. Run: git fetch {remote_name}")
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
# Distinguish behind / ahead / diverged via commit ancestry.
|
|
658
|
+
# `merge-base --is-ancestor A B` exits 0 when A is reachable from B.
|
|
659
|
+
rc_local_in_remote, _, _ = _git(
|
|
660
|
+
["merge-base", "--is-ancestor", "HEAD", remote_sha])
|
|
661
|
+
rc_remote_in_local, _, _ = _git(
|
|
662
|
+
["merge-base", "--is-ancestor", remote_sha, "HEAD"])
|
|
663
|
+
# exit 128 = git error (still-missing object, corrupt repo, …) — don't
|
|
664
|
+
# pretend we know the relationship.
|
|
665
|
+
if rc_local_in_remote == 128 or rc_remote_in_local == 128:
|
|
666
|
+
print("Could not compare ancestry. Run: git fetch && git status")
|
|
667
|
+
return 0
|
|
668
|
+
if rc_local_in_remote == 0:
|
|
669
|
+
print("Update available. Run: git pull")
|
|
670
|
+
print("(then `uv pip install -e .` if dependencies changed)")
|
|
671
|
+
elif rc_remote_in_local == 0:
|
|
672
|
+
print("Local is ahead of remote (unpushed commits). No update needed.")
|
|
673
|
+
else:
|
|
674
|
+
print("Branches have diverged. Run: git pull --rebase (or merge) and resolve.")
|
|
675
|
+
return 0
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
# mcp print / add
|
|
680
|
+
# ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
_SUPPORTED_CLIENTS = ("claude", "trae")
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _server_py() -> str:
|
|
686
|
+
return os.path.join(_HERE, "server.py")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _user_cache_dir() -> str:
|
|
690
|
+
"""Platform cache root for sql-buddy (backups, etc.)."""
|
|
691
|
+
if sys.platform == "win32":
|
|
692
|
+
base = os.environ.get("LOCALAPPDATA") or os.path.join(
|
|
693
|
+
os.path.expanduser("~"), "AppData", "Local")
|
|
694
|
+
return os.path.join(base, "sql-buddy")
|
|
695
|
+
if sys.platform == "darwin":
|
|
696
|
+
return os.path.join(os.path.expanduser("~"), "Library", "Caches",
|
|
697
|
+
"sql-buddy")
|
|
698
|
+
return os.path.join(os.path.expanduser("~"), ".cache", "sql-buddy")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _norm_mcp_path(path: str) -> str:
|
|
702
|
+
"""Absolute, normalized path for embedding in MCP JSON."""
|
|
703
|
+
return os.path.normpath(os.path.abspath(path))
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _resolve_exe(name: str) -> str | None:
|
|
707
|
+
"""Absolute path to a PATH executable, or None."""
|
|
708
|
+
found = shutil.which(name)
|
|
709
|
+
if not found:
|
|
710
|
+
return None
|
|
711
|
+
return _norm_mcp_path(found)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _mcp_env(client: str) -> dict[str, str]:
|
|
715
|
+
"""Env block for MCP entries. Unbuffered stdio + Windows UTF-8 + Trae timeouts."""
|
|
716
|
+
# Unbuffered so JSON-RPC over stdio is never delayed by block buffering.
|
|
717
|
+
env: dict[str, str] = {"PYTHONUNBUFFERED": "1"}
|
|
718
|
+
if sys.platform == "win32":
|
|
719
|
+
# Chinese/legacy Windows code pages break JSON-RPC over stdio otherwise.
|
|
720
|
+
env["PYTHONUTF8"] = "1"
|
|
721
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
722
|
+
if client == "trae":
|
|
723
|
+
# First `uv run` (venv provision) can exceed Trae's default start budget.
|
|
724
|
+
env["START_MCP_TIMEOUT_MS"] = "120000"
|
|
725
|
+
env["RUN_MCP_TIMEOUT_MS"] = "120000"
|
|
726
|
+
return env
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _command_has_spaces(cmd: str) -> bool:
|
|
730
|
+
return " " in (cmd or "")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _mcp_entry(python_mode: bool, *, project_relative: bool = False,
|
|
734
|
+
client: str = "claude", command_override: str = None) -> dict:
|
|
735
|
+
"""Build the mcpServers['sql-buddy'] entry, paths auto-filled.
|
|
736
|
+
|
|
737
|
+
Mode selection (see docs/adr/0002-config-discovery-and-invocation-mode-on-publish.md):
|
|
738
|
+
--command <cmd> explicit override (shlex-split into command + args)
|
|
739
|
+
--python the running interpreter + server.py
|
|
740
|
+
auto, checkout: `uv --directory <repo> run server.py` (auto-provisions
|
|
741
|
+
the venv; no absolute .venv path to go stale)
|
|
742
|
+
auto, installed: `uvx --from sqlbuddy-nwi sql-buddy` (PyPI; dist name
|
|
743
|
+
differs from the command), or `uv tool run --from
|
|
744
|
+
sqlbuddy-nwi sql-buddy`, or the `sql-buddy` console
|
|
745
|
+
script for a plain pip/pipx copy
|
|
746
|
+
|
|
747
|
+
`project_relative=True` writes `--directory .` so a checked-in `.mcp.json`
|
|
748
|
+
in this repo stays portable and is not dirtied with a machine-specific path.
|
|
749
|
+
|
|
750
|
+
On Windows / Trae we resolve bare names to absolute paths (GUI hosts often
|
|
751
|
+
lack the user's shell PATH). We never substitute `py -3` for sys.executable:
|
|
752
|
+
that would drop the active venv and its deps. If Trae cannot accept the
|
|
753
|
+
command path (spaces), callers must refuse rather than guess another Python.
|
|
754
|
+
"""
|
|
755
|
+
env = _mcp_env(client)
|
|
756
|
+
|
|
757
|
+
if command_override:
|
|
758
|
+
import shlex
|
|
759
|
+
parts = shlex.split(command_override)
|
|
760
|
+
if not parts:
|
|
761
|
+
raise SystemExit("--command requires a non-empty command string")
|
|
762
|
+
entry: dict = {"command": parts[0], "args": parts[1:]}
|
|
763
|
+
elif python_mode:
|
|
764
|
+
# Always the invoking interpreter — keeps the active uv/.venv deps.
|
|
765
|
+
entry = {"command": _norm_mcp_path(sys.executable),
|
|
766
|
+
"args": [_norm_mcp_path(_server_py())]}
|
|
767
|
+
else:
|
|
768
|
+
from connections.config_util import is_checkout
|
|
769
|
+
if is_checkout():
|
|
770
|
+
# Absolute uv only where GUI hosts need it. Bare `uv` keeps Claude's
|
|
771
|
+
# checked-in .mcp.json portable on Unix.
|
|
772
|
+
if client == "trae" or sys.platform == "win32":
|
|
773
|
+
uv = _resolve_exe("uv") or "uv"
|
|
774
|
+
else:
|
|
775
|
+
uv = "uv"
|
|
776
|
+
directory = "." if project_relative else _norm_mcp_path(_HERE)
|
|
777
|
+
entry = {"command": uv,
|
|
778
|
+
"args": ["--directory", directory, "run", "server.py"]}
|
|
779
|
+
else:
|
|
780
|
+
entry = _installed_entry(client)
|
|
781
|
+
|
|
782
|
+
if env:
|
|
783
|
+
entry["env"] = env
|
|
784
|
+
return entry
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _installed_entry(client: str) -> dict:
|
|
788
|
+
"""MCP entry for an installed (non-checkout) sql-buddy.
|
|
789
|
+
|
|
790
|
+
Prefer `uvx --from sqlbuddy-nwi sql-buddy` (the dist name differs from the
|
|
791
|
+
command name, so uvx needs --from; pulls the latest from PyPI into an
|
|
792
|
+
ephemeral venv); fall back to `uv tool run --from sqlbuddy-nwi sql-buddy` if
|
|
793
|
+
only `uv` is on PATH, then to the `sql-buddy` console script for a plain
|
|
794
|
+
pip/pipx install. GUI hosts (Trae) and Windows resolve bare names to
|
|
795
|
+
absolute paths so the host finds them without the user's shell PATH; Unix
|
|
796
|
+
non-Trae keeps them bare for portability.
|
|
797
|
+
"""
|
|
798
|
+
gui = client == "trae" or sys.platform == "win32"
|
|
799
|
+
|
|
800
|
+
def pick(name: str) -> str:
|
|
801
|
+
return (_resolve_exe(name) or name) if gui else name
|
|
802
|
+
|
|
803
|
+
if shutil.which("uvx"):
|
|
804
|
+
return {"command": pick("uvx"),
|
|
805
|
+
"args": ["--from", _DIST_NAME, "sql-buddy"]}
|
|
806
|
+
if shutil.which("uv"):
|
|
807
|
+
return {"command": pick("uv"),
|
|
808
|
+
"args": ["tool", "run", "--from", _DIST_NAME, "sql-buddy"]}
|
|
809
|
+
return {"command": pick("sql-buddy"), "args": []}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _mcp_mode_note(entry: dict, python_mode: bool, command_override: str | None,
|
|
813
|
+
rel: bool, checkout: bool) -> str:
|
|
814
|
+
"""Human label for the mode _mcp_entry selected (for add/print messages)."""
|
|
815
|
+
if command_override:
|
|
816
|
+
return f"command override ({command_override})"
|
|
817
|
+
if python_mode:
|
|
818
|
+
return "python (sys.executable)"
|
|
819
|
+
if checkout:
|
|
820
|
+
return "uv relative (.)" if rel else "uv (auto-provisions venv)"
|
|
821
|
+
args = entry.get("args", [])
|
|
822
|
+
if args == ["--from", _DIST_NAME, "sql-buddy"]:
|
|
823
|
+
return f"uvx --from {_DIST_NAME} sql-buddy (PyPI)"
|
|
824
|
+
if len(args) >= 4 and args[:4] == ["tool", "run", "--from", _DIST_NAME]:
|
|
825
|
+
return f"uv tool run --from {_DIST_NAME} sql-buddy (PyPI)"
|
|
826
|
+
return "sql-buddy (console script)"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _trae_command_space_error(cmd: str) -> str:
|
|
830
|
+
return (
|
|
831
|
+
f"refusing Trae config: command path contains spaces ({cmd!r}). "
|
|
832
|
+
"Trae's MCP host rejects a `command` field with spaces, and "
|
|
833
|
+
"substituting another Python (e.g. `py -3`) would drop this venv's "
|
|
834
|
+
"deps. Install uv (or move this checkout/venv) onto a path without "
|
|
835
|
+
"spaces, then re-run."
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def cmd_mcp_print(python_mode: bool, client: str = "claude",
|
|
840
|
+
command_override: str = None) -> int:
|
|
841
|
+
# Relative --directory only makes sense inside a checkout (Claude project),
|
|
842
|
+
# matching the committed .mcp.json template. Trae project files live under
|
|
843
|
+
# .trae/ and use absolute --directory.
|
|
844
|
+
from connections.config_util import is_checkout
|
|
845
|
+
checkout = is_checkout()
|
|
846
|
+
rel = (client == "claude" and checkout
|
|
847
|
+
and os.path.realpath(os.getcwd()) == os.path.realpath(_HERE))
|
|
848
|
+
# Only a checkout needs uv to auto-provision the venv; installed copies pick
|
|
849
|
+
# uvx/console-script inside _mcp_entry.
|
|
850
|
+
if (checkout and not python_mode and not command_override
|
|
851
|
+
and not shutil.which("uv")):
|
|
852
|
+
print("# note: 'uv' not found on PATH; this config requires it. "
|
|
853
|
+
"Use --python for a sys.executable-based config.\n",
|
|
854
|
+
file=sys.stderr)
|
|
855
|
+
if client == "trae" or sys.platform == "win32":
|
|
856
|
+
python_mode = True
|
|
857
|
+
entry = _mcp_entry(python_mode, project_relative=rel and not python_mode,
|
|
858
|
+
client=client, command_override=command_override)
|
|
859
|
+
if client == "trae" and _command_has_spaces(entry.get("command", "")):
|
|
860
|
+
print(f"# warning: {_trae_command_space_error(entry.get('command', ''))}\n",
|
|
861
|
+
file=sys.stderr)
|
|
862
|
+
print(json.dumps({"mcpServers": {"sql-buddy": entry}}, indent=2))
|
|
863
|
+
return 0
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _read_json_file(path: str) -> dict:
|
|
867
|
+
# utf-8-sig: Windows editors (and Trae) may write a BOM.
|
|
868
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
869
|
+
return json.load(f)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _write_json_file(path: str, data: dict) -> None:
|
|
873
|
+
"""Write JSON atomically, preserving the existing file mode.
|
|
874
|
+
|
|
875
|
+
MCP client configs (notably ~/.claude.json) are commonly mode 0600 and may
|
|
876
|
+
hold third-party API tokens. Writing through a naive temp file would reset
|
|
877
|
+
the mode to the process umask (often 0644), exposing them to other local
|
|
878
|
+
users. So: inherit the existing mode if the file exists, else create 0600;
|
|
879
|
+
and use a 0600 temp file so the data is never world-readable mid-write.
|
|
880
|
+
"""
|
|
881
|
+
d = os.path.dirname(path) or "."
|
|
882
|
+
os.makedirs(d, exist_ok=True)
|
|
883
|
+
mode = 0o600
|
|
884
|
+
if os.path.exists(path):
|
|
885
|
+
mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
886
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".sql-buddy-mcp-")
|
|
887
|
+
try:
|
|
888
|
+
try:
|
|
889
|
+
os.chmod(tmp, mode)
|
|
890
|
+
except OSError:
|
|
891
|
+
pass # Windows: chmod is best-effort
|
|
892
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
893
|
+
json.dump(data, f, indent=2)
|
|
894
|
+
f.write("\n")
|
|
895
|
+
os.replace(tmp, path)
|
|
896
|
+
finally:
|
|
897
|
+
if os.path.exists(tmp):
|
|
898
|
+
os.remove(tmp)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _client_config_path(client: str, scope: str) -> str:
|
|
902
|
+
client = (client or "claude").lower()
|
|
903
|
+
scope = (scope or "project").lower()
|
|
904
|
+
if client not in _SUPPORTED_CLIENTS:
|
|
905
|
+
raise SystemExit(
|
|
906
|
+
f"unsupported client '{client}' "
|
|
907
|
+
f"(want {', '.join(_SUPPORTED_CLIENTS)})"
|
|
908
|
+
)
|
|
909
|
+
home = os.path.expanduser("~")
|
|
910
|
+
if client == "claude":
|
|
911
|
+
if scope == "project":
|
|
912
|
+
return os.path.join(os.getcwd(), ".mcp.json")
|
|
913
|
+
if scope == "user":
|
|
914
|
+
return os.path.join(home, ".claude.json")
|
|
915
|
+
if client == "trae":
|
|
916
|
+
# Official: project-level MCP lives at <workspace>/.trae/mcp.json
|
|
917
|
+
# (enable "project-level MCP" in Trae Settings → MCP).
|
|
918
|
+
if scope == "project":
|
|
919
|
+
return os.path.join(os.getcwd(), ".trae", "mcp.json")
|
|
920
|
+
if scope == "user":
|
|
921
|
+
for cand in _trae_user_mcp_candidates():
|
|
922
|
+
if os.path.isfile(cand):
|
|
923
|
+
return cand
|
|
924
|
+
# Default create target (first platform candidate).
|
|
925
|
+
return _trae_user_mcp_candidates()[0]
|
|
926
|
+
raise SystemExit(f"unsupported scope '{scope}' (use 'project' or 'user')")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _mcp_backup_path(path: str) -> str:
|
|
930
|
+
"""Backup path OUTSIDE any git checkout (token-bearing client configs).
|
|
931
|
+
|
|
932
|
+
Project-scope `mcp add` may run inside an unrelated repository whose
|
|
933
|
+
.gitignore does not cover `.mcp.json.bak*`. Putting backups under the
|
|
934
|
+
user cache dir keeps them out of every working tree.
|
|
935
|
+
"""
|
|
936
|
+
import hashlib
|
|
937
|
+
cache = os.path.join(_user_cache_dir(), "mcp-backups")
|
|
938
|
+
os.makedirs(cache, mode=0o700, exist_ok=True)
|
|
939
|
+
try:
|
|
940
|
+
os.chmod(cache, 0o700)
|
|
941
|
+
except OSError:
|
|
942
|
+
pass
|
|
943
|
+
digest = hashlib.sha256(os.path.abspath(path).encode()).hexdigest()[:12]
|
|
944
|
+
base = os.path.basename(path)
|
|
945
|
+
n = 1
|
|
946
|
+
while True:
|
|
947
|
+
cand = os.path.join(cache, f"{base}.{digest}.bak{n}")
|
|
948
|
+
if not os.path.exists(cand):
|
|
949
|
+
return cand
|
|
950
|
+
n += 1
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def cmd_mcp_add(python_mode: bool, client: str, scope: str,
|
|
954
|
+
command_override: str = None) -> int:
|
|
955
|
+
path = _client_config_path(client, scope)
|
|
956
|
+
from connections.config_util import is_checkout
|
|
957
|
+
checkout = is_checkout()
|
|
958
|
+
# A checkout with no `uv` can't auto-provision its venv — fall back to the
|
|
959
|
+
# running interpreter so we never write a config the client can't launch.
|
|
960
|
+
# Installed copies select uvx/console-script inside _mcp_entry instead, so
|
|
961
|
+
# this fallback is checkout-only.
|
|
962
|
+
if (checkout and not python_mode and not command_override
|
|
963
|
+
and not shutil.which("uv")):
|
|
964
|
+
print("uv not on PATH; falling back to sys.executable "
|
|
965
|
+
"(pass --python to silence this).", file=sys.stderr)
|
|
966
|
+
python_mode = True
|
|
967
|
+
# Keep the committed .mcp.json portable when adding from inside this checkout.
|
|
968
|
+
# Trae project files are per-workspace (.trae/mcp.json) — always absolute.
|
|
969
|
+
rel = (client == "claude"
|
|
970
|
+
and scope == "project"
|
|
971
|
+
and checkout
|
|
972
|
+
and os.path.realpath(os.getcwd()) == os.path.realpath(_HERE)
|
|
973
|
+
and not python_mode
|
|
974
|
+
and not command_override)
|
|
975
|
+
entry = _mcp_entry(python_mode, project_relative=rel, client=client,
|
|
976
|
+
command_override=command_override)
|
|
977
|
+
if client == "trae" and _command_has_spaces(entry.get("command", "")):
|
|
978
|
+
raise SystemExit(_trae_command_space_error(entry.get("command", "")))
|
|
979
|
+
mode_note = _mcp_mode_note(entry, python_mode, command_override, rel, checkout)
|
|
980
|
+
if os.path.exists(path):
|
|
981
|
+
try:
|
|
982
|
+
data = _read_json_file(path)
|
|
983
|
+
if not isinstance(data, dict):
|
|
984
|
+
raise ValueError("top-level JSON is not an object")
|
|
985
|
+
except Exception as e:
|
|
986
|
+
raise SystemExit(f"{path} exists but is not valid JSON ({e}); "
|
|
987
|
+
"fix or remove it first")
|
|
988
|
+
else:
|
|
989
|
+
data = {}
|
|
990
|
+
servers = data.setdefault("mcpServers", {})
|
|
991
|
+
if not isinstance(servers, dict):
|
|
992
|
+
raise SystemExit(f"{path} has 'mcpServers' that is not an object; fix it first")
|
|
993
|
+
# No-op if already equivalent — avoids dirtying the tracked portable .mcp.json.
|
|
994
|
+
existing = servers.get("sql-buddy")
|
|
995
|
+
if existing == entry:
|
|
996
|
+
print(f"sql-buddy already configured in {path} ({mode_note}); no change.")
|
|
997
|
+
return 0
|
|
998
|
+
if os.path.exists(path):
|
|
999
|
+
# Backups live under the user cache dir (never adjacent to a foreign
|
|
1000
|
+
# project's working tree where they could be committed).
|
|
1001
|
+
bak = _mcp_backup_path(path)
|
|
1002
|
+
shutil.copy2(path, bak)
|
|
1003
|
+
try:
|
|
1004
|
+
os.chmod(bak, 0o600)
|
|
1005
|
+
except OSError:
|
|
1006
|
+
pass
|
|
1007
|
+
print(f"Backed up existing config to {bak}")
|
|
1008
|
+
servers["sql-buddy"] = entry
|
|
1009
|
+
_write_json_file(path, data)
|
|
1010
|
+
print(f"Wrote sql-buddy into {path} "
|
|
1011
|
+
f"(client: {client}, scope: {scope}, mode: {mode_note})")
|
|
1012
|
+
if client == "trae" and scope == "project":
|
|
1013
|
+
print("Trae: enable Settings → MCP → 'project-level MCP', then restart.")
|
|
1014
|
+
else:
|
|
1015
|
+
print("Restart your MCP client to pick it up.")
|
|
1016
|
+
return 0
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ---------------------------------------------------------------------------
|
|
1020
|
+
# help / dispatcher
|
|
1021
|
+
# ---------------------------------------------------------------------------
|
|
1022
|
+
|
|
1023
|
+
_USAGE = """\
|
|
1024
|
+
sql-buddy {ver} — read-only MCP database server
|
|
1025
|
+
|
|
1026
|
+
Usage:
|
|
1027
|
+
sql-buddy [serve|stdio] run the MCP server over stdio (default)
|
|
1028
|
+
sql-buddy version print version
|
|
1029
|
+
sql-buddy doctor [--connect] validate install + db.ini (+ live pings)
|
|
1030
|
+
sql-buddy update-check compare local checkout to the git remote
|
|
1031
|
+
sql-buddy mcp print [--python] [--command CMD] [--client claude|trae]
|
|
1032
|
+
print the MCP client config block
|
|
1033
|
+
sql-buddy mcp add [--client claude|trae] [--scope project|user] [--python] [--command CMD]
|
|
1034
|
+
write the config into a client's config file
|
|
1035
|
+
sql-buddy init interactive setup wizard
|
|
1036
|
+
sql-buddy help show this help
|
|
1037
|
+
|
|
1038
|
+
Bare `sql-buddy` (no args) starts the MCP server, so MCP client configs that
|
|
1039
|
+
invoke `sql-buddy` or `python server.py` keep working unchanged.
|
|
1040
|
+
|
|
1041
|
+
Clients:
|
|
1042
|
+
claude .mcp.json (project) or ~/.claude.json (user) [default]
|
|
1043
|
+
trae .trae/mcp.json (project) or Trae User/mcp.json (user)
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def cmd_help() -> int:
|
|
1048
|
+
print(_USAGE.format(ver=get_version()))
|
|
1049
|
+
return 0
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _run_server() -> int:
|
|
1053
|
+
# Lazy import: keeps subcommands lightweight and avoids importing the MCP
|
|
1054
|
+
# runtime / DB drivers when the user only asked for `version` or `doctor`.
|
|
1055
|
+
import server
|
|
1056
|
+
server.run_server()
|
|
1057
|
+
return 0
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1061
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
1062
|
+
if not args or args[0] in ("serve", "server", "stdio"):
|
|
1063
|
+
return _run_server()
|
|
1064
|
+
|
|
1065
|
+
cmd, rest = args[0], args[1:]
|
|
1066
|
+
|
|
1067
|
+
if cmd in ("version", "--version", "-V"):
|
|
1068
|
+
return cmd_version()
|
|
1069
|
+
if cmd in ("help", "--help", "-h", "-?"):
|
|
1070
|
+
return cmd_help()
|
|
1071
|
+
if cmd == "doctor":
|
|
1072
|
+
connect = "--connect" in rest
|
|
1073
|
+
return doctor(connect=connect)
|
|
1074
|
+
if cmd == "update-check":
|
|
1075
|
+
return update_check()
|
|
1076
|
+
if cmd == "init":
|
|
1077
|
+
import init
|
|
1078
|
+
# Pass `rest` explicitly: init.main() uses argparse, which reads
|
|
1079
|
+
# sys.argv — so we must not leave the literal "init" token in argv or
|
|
1080
|
+
# argparse rejects it as an unrecognized argument.
|
|
1081
|
+
return init.main(rest) or 0
|
|
1082
|
+
if cmd == "mcp":
|
|
1083
|
+
if not rest:
|
|
1084
|
+
print("Usage: sql-buddy mcp <print|add> [--python] [--command CMD] "
|
|
1085
|
+
"[--client claude|trae] [--scope project|user]",
|
|
1086
|
+
file=sys.stderr)
|
|
1087
|
+
return 2
|
|
1088
|
+
sub = rest[0]
|
|
1089
|
+
flags = rest[1:]
|
|
1090
|
+
try:
|
|
1091
|
+
parsed = _parse_mcp_flags(flags)
|
|
1092
|
+
except SystemExit as e:
|
|
1093
|
+
print(str(e) or "bad flags", file=sys.stderr)
|
|
1094
|
+
return 2
|
|
1095
|
+
if sub == "print":
|
|
1096
|
+
return cmd_mcp_print(parsed["python"], client=parsed["client"],
|
|
1097
|
+
command_override=parsed["command"])
|
|
1098
|
+
if sub == "add":
|
|
1099
|
+
return cmd_mcp_add(parsed["python"], client=parsed["client"],
|
|
1100
|
+
scope=parsed["scope"],
|
|
1101
|
+
command_override=parsed["command"])
|
|
1102
|
+
print(f"Unknown 'mcp' subcommand: {sub} (want print|add)", file=sys.stderr)
|
|
1103
|
+
return 2
|
|
1104
|
+
|
|
1105
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
1106
|
+
cmd_help()
|
|
1107
|
+
return 2
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _parse_mcp_flags(flags: list[str]) -> dict:
|
|
1111
|
+
"""Strict flag parser for `mcp print|add`. Unknown / valueless flags error."""
|
|
1112
|
+
out = {"python": False, "client": "claude", "scope": "project", "command": None}
|
|
1113
|
+
known = {"--python", "--client", "--scope", "--command"}
|
|
1114
|
+
i = 0
|
|
1115
|
+
while i < len(flags):
|
|
1116
|
+
f = flags[i]
|
|
1117
|
+
if f == "--python":
|
|
1118
|
+
out["python"] = True
|
|
1119
|
+
i += 1
|
|
1120
|
+
continue
|
|
1121
|
+
if f.startswith("--command="):
|
|
1122
|
+
out["command"] = f[len("--command="):]
|
|
1123
|
+
if not out["command"]:
|
|
1124
|
+
raise SystemExit("--command requires a value")
|
|
1125
|
+
i += 1
|
|
1126
|
+
continue
|
|
1127
|
+
if f == "--command":
|
|
1128
|
+
# A command string may itself contain leading-dash tokens, so do
|
|
1129
|
+
# NOT treat a '-'-prefixed next token as a missing value here.
|
|
1130
|
+
if i + 1 >= len(flags):
|
|
1131
|
+
raise SystemExit("--command requires a value (got end of args)")
|
|
1132
|
+
out["command"] = flags[i + 1]
|
|
1133
|
+
i += 2
|
|
1134
|
+
continue
|
|
1135
|
+
if f.startswith("--client=") or f.startswith("--scope="):
|
|
1136
|
+
name, _, val = f.partition("=")
|
|
1137
|
+
key = name[2:] # strip --
|
|
1138
|
+
if not val:
|
|
1139
|
+
raise SystemExit(f"{name} requires a value")
|
|
1140
|
+
out[key] = val
|
|
1141
|
+
i += 1
|
|
1142
|
+
continue
|
|
1143
|
+
if f in ("--client", "--scope"):
|
|
1144
|
+
if i + 1 >= len(flags) or flags[i + 1].startswith("-"):
|
|
1145
|
+
raise SystemExit(f"{f} requires a value (got end of args)")
|
|
1146
|
+
out[f[2:]] = flags[i + 1]
|
|
1147
|
+
i += 2
|
|
1148
|
+
continue
|
|
1149
|
+
if f.startswith("-"):
|
|
1150
|
+
raise SystemExit(f"unknown flag: {f} (want {', '.join(sorted(known))})")
|
|
1151
|
+
raise SystemExit(f"unexpected argument: {f}")
|
|
1152
|
+
out["client"] = (out["client"] or "").lower()
|
|
1153
|
+
if out["scope"] not in ("project", "user"):
|
|
1154
|
+
raise SystemExit(f"unsupported --scope '{out['scope']}' (want project|user)")
|
|
1155
|
+
if out["client"] not in _SUPPORTED_CLIENTS:
|
|
1156
|
+
raise SystemExit(
|
|
1157
|
+
f"unsupported --client '{out['client']}' "
|
|
1158
|
+
f"(want {', '.join(_SUPPORTED_CLIENTS)})"
|
|
1159
|
+
)
|
|
1160
|
+
return out
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
if __name__ == "__main__":
|
|
1164
|
+
sys.exit(main())
|