dsh-db-mcp 0.6.4

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.
package/server/main.py ADDED
@@ -0,0 +1,260 @@
1
+ """db-mcp: the MCP server that lets the agent read the databases registered in
2
+ the dsh-db-mcp plugin panel.
3
+
4
+ Run over stdio (the plugin Host half spawns it):
5
+
6
+ python main.py
7
+
8
+ Every tool re-reads `datasources.json` and `secrets.json`, so sources added or
9
+ edited in the panel are visible without a restart. Tools never return passwords.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import Any, Optional
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ import engines
22
+ import store
23
+
24
+ DEFAULT_MAX_ROWS = int(os.environ.get("DB_MCP_MAX_ROWS") or 200)
25
+ HARD_MAX_ROWS = int(os.environ.get("DB_MCP_MAX_ROWS_HARD") or 2000)
26
+
27
+ mcp = FastMCP(
28
+ "db-mcp",
29
+ instructions=(
30
+ "Query the databases the user registered in the DSH Database panel. "
31
+ "Start with list_datasources, then list_tables, describe_table and finally "
32
+ "run_query. Prefer these tools over guessing table names."
33
+ ),
34
+ )
35
+
36
+
37
+ def _clamp_rows(limit: Optional[int]) -> int:
38
+ try:
39
+ value = int(limit) if limit is not None else DEFAULT_MAX_ROWS
40
+ except (TypeError, ValueError):
41
+ value = DEFAULT_MAX_ROWS
42
+ if value <= 0:
43
+ value = DEFAULT_MAX_ROWS
44
+ return min(value, HARD_MAX_ROWS)
45
+
46
+
47
+ def _error(error: Exception) -> dict:
48
+ if isinstance(error, (engines.DbError, store.StoreError)):
49
+ payload = {"ok": False, "error": {"code": error.code, "message": error.message}}
50
+ if error.hint:
51
+ payload["error"]["hint"] = error.hint
52
+ return payload
53
+ return {
54
+ "ok": False,
55
+ "error": {"code": "infra_error", "message": f"{type(error).__name__}: {error}"},
56
+ }
57
+
58
+
59
+ def _open(datasource: Optional[str]):
60
+ profile, secrets = store.resolve(datasource)
61
+ password = store.password_of(profile, secrets)
62
+ return profile, engines.connect(profile, password)
63
+
64
+
65
+ def _source(profile: dict) -> dict:
66
+ config = profile.get("config") or {}
67
+ kind = profile.get("kind")
68
+ if kind == "sqlite":
69
+ target = config.get("file") or ""
70
+ elif kind == "oracle":
71
+ target = f"{config.get('host')}:{config.get('port') or 1521}/{config.get('serviceName') or config.get('sid') or ''}"
72
+ else:
73
+ target = f"{config.get('host')}:{config.get('port') or ''}/{config.get('database') or ''}"
74
+ return {
75
+ "id": profile.get("id"),
76
+ "name": profile.get("name"),
77
+ "kind": kind,
78
+ "readOnly": bool(profile.get("readOnly")),
79
+ "isDefault": bool(profile.get("isDefault")),
80
+ "target": target,
81
+ }
82
+
83
+
84
+ def _inline_profile(kind: str, config: Any, read_only: Optional[bool]) -> dict:
85
+ if not isinstance(config, dict):
86
+ raise store.StoreError("user_error", "inline test needs a config object", "pass kind plus config")
87
+ if kind not in ("sqlite", "postgres", "mysql", "oracle"):
88
+ raise store.StoreError("user_error", f"unsupported datasource kind: {kind}")
89
+ return {
90
+ "id": None,
91
+ "name": "inline test",
92
+ "kind": kind,
93
+ "config": config,
94
+ "readOnly": True if read_only is None else bool(read_only),
95
+ "isDefault": False,
96
+ "tags": [],
97
+ }
98
+
99
+
100
+ @mcp.tool()
101
+ def test_connection(
102
+ datasource: Optional[str] = None,
103
+ kind: Optional[str] = None,
104
+ config: Optional[dict] = None,
105
+ password: Optional[str] = None,
106
+ read_only: Optional[bool] = None,
107
+ ) -> dict:
108
+ """Log in to a database and run one trivial query, without returning data.
109
+
110
+ Use a stored `datasource` id, or pass `kind` plus `config` (and optionally
111
+ `password`, `read_only`) to test a connection that has not been saved yet.
112
+ Returns the server banner, the connected user, the database/schema and how
113
+ long the login took.
114
+ """
115
+ try:
116
+ if kind and config is not None:
117
+ profile = _inline_profile(kind, config, read_only)
118
+ secret = password
119
+ else:
120
+ profile, secrets = store.resolve(datasource)
121
+ secret = password if password is not None else store.password_of(profile, secrets)
122
+ result = engines.probe(profile, secret)
123
+ return {**result, "datasource": _source(profile)}
124
+ except Exception as error: # noqa: BLE001 - reported as structured data
125
+ payload = _error(error)
126
+ payload["latencyMs"] = 0
127
+ return payload
128
+
129
+
130
+ @mcp.tool()
131
+ def list_datasources() -> dict:
132
+ """List the database connections the user registered in DSH.
133
+
134
+ Call this first. Use the returned `id` as the `datasource` argument of the
135
+ other tools. The default datasource may be omitted everywhere.
136
+ """
137
+ try:
138
+ sources = store.all_sources()
139
+ return {
140
+ "ok": True,
141
+ "datasources": [_source(source) for source in sources],
142
+ "count": len(sources),
143
+ }
144
+ except Exception as error: # noqa: BLE001 - reported as structured data
145
+ return _error(error)
146
+
147
+
148
+ @mcp.tool()
149
+ def list_tables(datasource: Optional[str] = None, schema: Optional[str] = None) -> dict:
150
+ """List the tables and views of one datasource.
151
+
152
+ `datasource` is an id from list_datasources; omit it to use the default
153
+ source. `schema` narrows the listing when the engine has several schemas.
154
+ """
155
+ try:
156
+ profile, (connection, close) = _open(datasource)
157
+ try:
158
+ result = engines.list_tables(profile, connection, schema)
159
+ finally:
160
+ close()
161
+ return {"ok": True, "datasource": _source(profile), **result}
162
+ except Exception as error: # noqa: BLE001
163
+ return _error(error)
164
+
165
+
166
+ @mcp.tool()
167
+ def describe_table(table: str, datasource: Optional[str] = None, schema: Optional[str] = None) -> dict:
168
+ """Describe one table: column names, types, nullability, defaults and primary key.
169
+
170
+ Call this before writing SQL so the column names are exact.
171
+ """
172
+ try:
173
+ profile, (connection, close) = _open(datasource)
174
+ try:
175
+ result = engines.describe_table(profile, connection, table, schema)
176
+ finally:
177
+ close()
178
+ return {"ok": True, "datasource": _source(profile), **result}
179
+ except Exception as error: # noqa: BLE001
180
+ return _error(error)
181
+
182
+
183
+ @mcp.tool()
184
+ def sample_rows(
185
+ table: str,
186
+ datasource: Optional[str] = None,
187
+ limit: int = 20,
188
+ schema: Optional[str] = None,
189
+ ) -> dict:
190
+ """Return a few rows from one table, to see what the data looks like."""
191
+ try:
192
+ rows = _clamp_rows(limit)
193
+ profile, (connection, close) = _open(datasource)
194
+ try:
195
+ result = engines.sample_rows(profile, connection, table, rows, schema)
196
+ finally:
197
+ close()
198
+ return {"ok": True, "datasource": _source(profile), **result}
199
+ except Exception as error: # noqa: BLE001
200
+ return _error(error)
201
+
202
+
203
+ @mcp.tool()
204
+ def run_query(sql: str, datasource: Optional[str] = None, max_rows: Optional[int] = None) -> dict:
205
+ """Run one SQL statement and return columns plus rows.
206
+
207
+ Read-only datasources accept SELECT/WITH/SHOW/EXPLAIN statements only.
208
+ Results are capped by `max_rows` (default 200); `truncated` says whether more
209
+ rows existed.
210
+ """
211
+ try:
212
+ limit = _clamp_rows(max_rows)
213
+ profile, (connection, close) = _open(datasource)
214
+ try:
215
+ result = engines.run_query(profile, connection, sql, limit)
216
+ finally:
217
+ close()
218
+ return {"ok": True, "datasource": _source(profile), **result}
219
+ except Exception as error: # noqa: BLE001
220
+ return _error(error)
221
+
222
+
223
+ @mcp.tool()
224
+ def explain_query(sql: str, datasource: Optional[str] = None) -> dict:
225
+ """Show the query plan of one statement, without running it."""
226
+ try:
227
+ profile, (connection, close) = _open(datasource)
228
+ try:
229
+ result = engines.explain_query(profile, connection, sql)
230
+ finally:
231
+ close()
232
+ return {"ok": True, "datasource": _source(profile), **result}
233
+ except Exception as error: # noqa: BLE001
234
+ return _error(error)
235
+
236
+
237
+ @mcp.tool()
238
+ def get_ddl(table: str, datasource: Optional[str] = None, schema: Optional[str] = None) -> dict:
239
+ """Show the CREATE TABLE statement of one table.
240
+
241
+ SQLite returns the statement recorded at creation time; PostgreSQL, MySQL
242
+ and Oracle have theirs rebuilt from the column metadata.
243
+ """
244
+ try:
245
+ profile, (connection, close) = _open(datasource)
246
+ try:
247
+ result = engines.table_ddl(profile, connection, table, schema)
248
+ finally:
249
+ close()
250
+ return {"ok": True, "datasource": _source(profile), **result}
251
+ except Exception as error: # noqa: BLE001
252
+ return _error(error)
253
+
254
+
255
+ def main() -> None:
256
+ mcp.run(transport="stdio")
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
@@ -0,0 +1,10 @@
1
+ # Installed into <DSH_HOME>/db-mcp/.venv by the Database panel (P5 will do this
2
+ # automatically). Until then:
3
+ #
4
+ # <DSH_HOME>\db-mcp\.venv\Scripts\python.exe -m pip install -r server\requirements.txt
5
+ #
6
+ # mcp 1.x is required: 2.x renamed FastMCP to MCPServer and moved the module.
7
+ mcp>=1.9,<2
8
+ psycopg[binary]>=3.2
9
+ PyMySQL>=1.1
10
+ oracledb>=2.0
@@ -0,0 +1,98 @@
1
+ """Registry access for the db-mcp MCP server.
2
+
3
+ The plugin Host half owns the files; this module only reads them. Every tool call
4
+ re-reads the registry, so a source added or edited in the panel is visible to the
5
+ agent without restarting the server process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ SCHEMA_VERSION = 1
15
+
16
+
17
+ class StoreError(Exception):
18
+ def __init__(self, code: str, message: str, hint: str | None = None):
19
+ super().__init__(message)
20
+ self.code = code
21
+ self.message = message
22
+ self.hint = hint
23
+
24
+
25
+ def runtime_dir() -> Path:
26
+ override = os.environ.get("DB_MCP_RUNTIME")
27
+ if override:
28
+ return Path(override)
29
+ home = os.environ.get("DSH_HOME")
30
+ if not home:
31
+ home = str(Path.home() / ".dsh")
32
+ return Path(home) / "db-mcp"
33
+
34
+
35
+ def _read_json(path: Path, default):
36
+ try:
37
+ with path.open("r", encoding="utf-8") as handle:
38
+ return json.load(handle)
39
+ except FileNotFoundError:
40
+ return default
41
+ except json.JSONDecodeError as error:
42
+ raise StoreError("infra_error", f"{path.name} is not valid JSON: {error}")
43
+
44
+
45
+ def load_registry() -> dict:
46
+ data = _read_json(runtime_dir() / "datasources.json", {"schemaVersion": SCHEMA_VERSION, "datasources": []})
47
+ sources = data.get("datasources")
48
+ if not isinstance(sources, list):
49
+ raise StoreError("infra_error", "datasources.json has no datasources array")
50
+ return data
51
+
52
+
53
+ def load_secrets() -> dict:
54
+ data = _read_json(runtime_dir() / "secrets.json", {"secrets": {}})
55
+ secrets = data.get("secrets")
56
+ return secrets if isinstance(secrets, dict) else {}
57
+
58
+
59
+ def password_of(profile: dict, secrets: dict | None = None) -> str | None:
60
+ secrets = secrets if secrets is not None else load_secrets()
61
+ entry = secrets.get(profile.get("id"))
62
+ if not isinstance(entry, dict):
63
+ return None
64
+ for key in ("password", "#password"):
65
+ value = entry.get(key)
66
+ if isinstance(value, str) and value != "":
67
+ return value
68
+ return None
69
+
70
+
71
+ def all_sources() -> list[dict]:
72
+ return list(load_registry().get("datasources") or [])
73
+
74
+
75
+ def resolve(name: str | None) -> tuple[dict, dict]:
76
+ """Resolve a datasource by id or name; fall back to the default source."""
77
+ sources = all_sources()
78
+ if not sources:
79
+ raise StoreError(
80
+ "user_error",
81
+ "no datasource is configured yet",
82
+ "open the Database panel in DSH and add one, or import from DBeaver",
83
+ )
84
+ if name:
85
+ wanted = name.strip()
86
+ for source in sources:
87
+ if source.get("id") == wanted:
88
+ return source, load_secrets()
89
+ folded = wanted.casefold()
90
+ for source in sources:
91
+ if str(source.get("name") or "").casefold() == folded:
92
+ return source, load_secrets()
93
+ known = ", ".join(str(source.get("id")) for source in sources)
94
+ raise StoreError("user_error", f"unknown datasource: {name}", f"known datasources: {known}")
95
+ for source in sources:
96
+ if source.get("isDefault"):
97
+ return source, load_secrets()
98
+ return sources[0], load_secrets()