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/LICENSE +202 -0
- package/README.md +326 -0
- package/client.js +2939 -0
- package/cordis.patch.yml +6 -0
- package/docs/requirements.md +960 -0
- package/icon.svg +8 -0
- package/index.js +119 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -0
- package/server/engines.py +617 -0
- package/server/main.py +260 -0
- package/server/requirements.txt +10 -0
- package/server/store.py +98 -0
- package/src/host.js +1507 -0
- package/src/mcp.js +264 -0
- package/src/tools.js +372 -0
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
|
package/server/store.py
ADDED
|
@@ -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()
|