Upload folder using huggingface_hub
Browse files- mcp_database_universal/__init__.py +1 -1
- mcp_database_universal/__main__.py +3 -0
- mcp_database_universal/__pycache__/__init__.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/__main__.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/config.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/nl2sql.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/safety.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/schema_inspector.cpython-311.pyc +0 -0
- mcp_database_universal/__pycache__/server.cpython-311.pyc +0 -0
- mcp_database_universal/engines/__pycache__/__init__.cpython-311.pyc +0 -0
- mcp_database_universal/engines/__pycache__/base.cpython-311.pyc +0 -0
- mcp_database_universal/engines/__pycache__/mysql.cpython-311.pyc +0 -0
- mcp_database_universal/engines/__pycache__/postgres.cpython-311.pyc +0 -0
- mcp_database_universal/engines/__pycache__/sqlite.cpython-311.pyc +0 -0
- mcp_database_universal/engines/base.py +19 -0
- mcp_database_universal/engines/mssql.py +1 -0
- mcp_database_universal/engines/mysql.py +3 -1
- mcp_database_universal/engines/postgres.py +35 -22
- mcp_database_universal/formatters/__pycache__/__init__.cpython-311.pyc +0 -0
- mcp_database_universal/formatters/__pycache__/llm.cpython-311.pyc +0 -0
- mcp_database_universal/nl2sql.py +269 -0
- mcp_database_universal/server.py +24 -42
mcp_database_universal/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
"""MCP Database Server — Reasoning interface for databases."""
|
| 2 |
|
| 3 |
-
__version__ = "0.1.
|
|
|
|
| 1 |
"""MCP Database Server — Reasoning interface for databases."""
|
| 2 |
|
| 3 |
+
__version__ = "0.1.3"
|
mcp_database_universal/__main__.py
CHANGED
|
@@ -88,4 +88,7 @@ async def main():
|
|
| 88 |
|
| 89 |
|
| 90 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
| 91 |
asyncio.run(main())
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
if __name__ == "__main__":
|
| 91 |
+
if sys.platform == "win32":
|
| 92 |
+
# psycopg async does not work with ProactorEventLoop (Windows default)
|
| 93 |
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
| 94 |
asyncio.run(main())
|
mcp_database_universal/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (288 Bytes). View file
|
|
|
mcp_database_universal/__pycache__/__main__.cpython-311.pyc
ADDED
|
Binary file (3.87 kB). View file
|
|
|
mcp_database_universal/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (3.23 kB). View file
|
|
|
mcp_database_universal/__pycache__/nl2sql.cpython-311.pyc
ADDED
|
Binary file (14.1 kB). View file
|
|
|
mcp_database_universal/__pycache__/safety.cpython-311.pyc
ADDED
|
Binary file (7.22 kB). View file
|
|
|
mcp_database_universal/__pycache__/schema_inspector.cpython-311.pyc
ADDED
|
Binary file (6.82 kB). View file
|
|
|
mcp_database_universal/__pycache__/server.cpython-311.pyc
ADDED
|
Binary file (12.7 kB). View file
|
|
|
mcp_database_universal/engines/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (2.63 kB). View file
|
|
|
mcp_database_universal/engines/__pycache__/base.cpython-311.pyc
ADDED
|
Binary file (8.05 kB). View file
|
|
|
mcp_database_universal/engines/__pycache__/mysql.cpython-311.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
mcp_database_universal/engines/__pycache__/postgres.cpython-311.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
mcp_database_universal/engines/__pycache__/sqlite.cpython-311.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
mcp_database_universal/engines/base.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Abstract base class for database engines."""
|
| 2 |
|
|
|
|
| 3 |
from abc import ABC, abstractmethod
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
|
|
@@ -79,10 +80,28 @@ class QueryResult:
|
|
| 79 |
warning: str | None = None
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
class BaseEngine(ABC):
|
| 83 |
@abstractmethod
|
| 84 |
async def connect(self) -> None: ...
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@abstractmethod
|
| 87 |
async def disconnect(self) -> None: ...
|
| 88 |
|
|
|
|
| 1 |
"""Abstract base class for database engines."""
|
| 2 |
|
| 3 |
+
import re
|
| 4 |
from abc import ABC, abstractmethod
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
|
|
|
|
| 80 |
warning: str | None = None
|
| 81 |
|
| 82 |
|
| 83 |
+
_NAMED_PARAM_RE = re.compile(r":([A-Za-z_][A-Za-z0-9_]*)")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _named_to_pyformat(sql: str) -> str:
|
| 87 |
+
"""Convert :name named params (SQLite style) to %(name)s (DBAPI pyformat style).
|
| 88 |
+
|
| 89 |
+
Used by psycopg (PostgreSQL), pymysql (MySQL) and MSSQL drivers,
|
| 90 |
+
which do not understand the :name syntax.
|
| 91 |
+
"""
|
| 92 |
+
return _NAMED_PARAM_RE.sub(r"%(\1)s", sql)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
class BaseEngine(ABC):
|
| 96 |
@abstractmethod
|
| 97 |
async def connect(self) -> None: ...
|
| 98 |
|
| 99 |
+
def _translate_params(self, sql: str, params: dict | None) -> tuple[str, dict | None]:
|
| 100 |
+
"""Normalize :name params to the driver-native style when params are given."""
|
| 101 |
+
if params:
|
| 102 |
+
sql = _named_to_pyformat(sql)
|
| 103 |
+
return sql, params
|
| 104 |
+
|
| 105 |
@abstractmethod
|
| 106 |
async def disconnect(self) -> None: ...
|
| 107 |
|
mcp_database_universal/engines/mssql.py
CHANGED
|
@@ -134,6 +134,7 @@ class MSSQLEngine(BaseEngine):
|
|
| 134 |
conn = self._ensure_conn()
|
| 135 |
start = time.monotonic()
|
| 136 |
try:
|
|
|
|
| 137 |
cur = conn.cursor()
|
| 138 |
if params:
|
| 139 |
cur.execute(sql, params)
|
|
|
|
| 134 |
conn = self._ensure_conn()
|
| 135 |
start = time.monotonic()
|
| 136 |
try:
|
| 137 |
+
sql, params = self._translate_params(sql, params)
|
| 138 |
cur = conn.cursor()
|
| 139 |
if params:
|
| 140 |
cur.execute(sql, params)
|
mcp_database_universal/engines/mysql.py
CHANGED
|
@@ -4,6 +4,7 @@ import time
|
|
| 4 |
from mcp_database_universal.engines.base import (
|
| 5 |
BaseEngine, DBInfo, ColumnInfo, TableInfo, TableDetail,
|
| 6 |
IndexInfo, ForeignKeyInfo, TableStats, QueryResult,
|
|
|
|
| 7 |
)
|
| 8 |
|
| 9 |
|
|
@@ -139,6 +140,7 @@ class MySQLEngine(BaseEngine):
|
|
| 139 |
conn = self._ensure_conn()
|
| 140 |
start = time.monotonic()
|
| 141 |
try:
|
|
|
|
| 142 |
with conn.cursor() as cur:
|
| 143 |
if params:
|
| 144 |
cur.execute(sql, params)
|
|
@@ -149,7 +151,7 @@ class MySQLEngine(BaseEngine):
|
|
| 149 |
elapsed = int((time.monotonic() - start) * 1000)
|
| 150 |
return QueryResult(
|
| 151 |
columns=columns,
|
| 152 |
-
rows=rows,
|
| 153 |
row_count=len(rows),
|
| 154 |
truncated=False,
|
| 155 |
execution_time_ms=elapsed,
|
|
|
|
| 4 |
from mcp_database_universal.engines.base import (
|
| 5 |
BaseEngine, DBInfo, ColumnInfo, TableInfo, TableDetail,
|
| 6 |
IndexInfo, ForeignKeyInfo, TableStats, QueryResult,
|
| 7 |
+
_named_to_pyformat,
|
| 8 |
)
|
| 9 |
|
| 10 |
|
|
|
|
| 140 |
conn = self._ensure_conn()
|
| 141 |
start = time.monotonic()
|
| 142 |
try:
|
| 143 |
+
sql, params = self._translate_params(sql, params)
|
| 144 |
with conn.cursor() as cur:
|
| 145 |
if params:
|
| 146 |
cur.execute(sql, params)
|
|
|
|
| 151 |
elapsed = int((time.monotonic() - start) * 1000)
|
| 152 |
return QueryResult(
|
| 153 |
columns=columns,
|
| 154 |
+
rows=list(rows),
|
| 155 |
row_count=len(rows),
|
| 156 |
truncated=False,
|
| 157 |
execution_time_ms=elapsed,
|
mcp_database_universal/engines/postgres.py
CHANGED
|
@@ -4,6 +4,7 @@ import time
|
|
| 4 |
from mcp_database_universal.engines.base import (
|
| 5 |
BaseEngine, DBInfo, ColumnInfo, TableInfo, TableDetail,
|
| 6 |
IndexInfo, ForeignKeyInfo, TableStats, QueryResult,
|
|
|
|
| 7 |
)
|
| 8 |
|
| 9 |
|
|
@@ -54,12 +55,14 @@ class PostgresEngine(BaseEngine):
|
|
| 54 |
|
| 55 |
async def get_db_info(self) -> DBInfo:
|
| 56 |
conn = self._ensure_conn()
|
| 57 |
-
|
|
|
|
| 58 |
version = row[0] if row else "unknown"
|
| 59 |
try:
|
| 60 |
-
|
| 61 |
"SELECT pg_size_pretty(pg_database_size(current_database()))"
|
| 62 |
-
)
|
|
|
|
| 63 |
size = row2[0] if row2 else "unknown"
|
| 64 |
except Exception:
|
| 65 |
size = "unknown"
|
|
@@ -67,27 +70,30 @@ class PostgresEngine(BaseEngine):
|
|
| 67 |
|
| 68 |
async def get_tables(self) -> list[TableInfo]:
|
| 69 |
conn = self._ensure_conn()
|
| 70 |
-
|
| 71 |
SELECT t.table_name
|
| 72 |
FROM information_schema.tables t
|
| 73 |
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
| 74 |
ORDER BY t.table_name
|
| 75 |
-
""")
|
|
|
|
| 76 |
|
| 77 |
tables = []
|
| 78 |
for row in rows:
|
| 79 |
name = row[0]
|
| 80 |
try:
|
| 81 |
-
|
|
|
|
| 82 |
row_count = cnt[0] if cnt else 0
|
| 83 |
except Exception:
|
| 84 |
row_count = 0
|
| 85 |
|
| 86 |
try:
|
| 87 |
-
|
| 88 |
SELECT COUNT(*) FROM information_schema.columns
|
| 89 |
WHERE table_schema = 'public' AND table_name = %s
|
| 90 |
-
""", [name])
|
|
|
|
| 91 |
col_count = cols[0] if cols else 0
|
| 92 |
except Exception:
|
| 93 |
col_count = 0
|
|
@@ -103,7 +109,7 @@ class PostgresEngine(BaseEngine):
|
|
| 103 |
async def get_table_detail(self, table: str) -> TableDetail:
|
| 104 |
conn = self._ensure_conn()
|
| 105 |
|
| 106 |
-
|
| 107 |
SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
|
| 108 |
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk
|
| 109 |
FROM information_schema.columns c
|
|
@@ -116,7 +122,8 @@ class PostgresEngine(BaseEngine):
|
|
| 116 |
) pk ON c.column_name = pk.column_name
|
| 117 |
WHERE c.table_schema = 'public' AND c.table_name = %s
|
| 118 |
ORDER BY c.ordinal_position
|
| 119 |
-
""", [table, table])
|
|
|
|
| 120 |
|
| 121 |
columns = []
|
| 122 |
pk_name = None
|
|
@@ -132,7 +139,7 @@ class PostgresEngine(BaseEngine):
|
|
| 132 |
is_primary_key=is_pk,
|
| 133 |
))
|
| 134 |
|
| 135 |
-
|
| 136 |
SELECT
|
| 137 |
kcu.column_name,
|
| 138 |
ccu.table_name AS foreign_table_name,
|
|
@@ -143,7 +150,8 @@ class PostgresEngine(BaseEngine):
|
|
| 143 |
JOIN information_schema.constraint_column_usage ccu
|
| 144 |
ON tc.constraint_name = ccu.constraint_name
|
| 145 |
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = %s
|
| 146 |
-
""", [table])
|
|
|
|
| 147 |
|
| 148 |
foreign_keys = []
|
| 149 |
fk_cols = set()
|
|
@@ -164,11 +172,12 @@ class PostgresEngine(BaseEngine):
|
|
| 164 |
col.foreign_key_column = fk.references_column
|
| 165 |
break
|
| 166 |
|
| 167 |
-
|
| 168 |
SELECT indexname, indexdef
|
| 169 |
FROM pg_indexes
|
| 170 |
WHERE tablename = %s AND schemaname = 'public'
|
| 171 |
-
""", [table])
|
|
|
|
| 172 |
|
| 173 |
indexes = []
|
| 174 |
for idx in idx_rows:
|
|
@@ -177,10 +186,11 @@ class PostgresEngine(BaseEngine):
|
|
| 177 |
indexes.append(IndexInfo(name=name, columns=[], unique=unique))
|
| 178 |
|
| 179 |
try:
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
else:
|
| 185 |
sample_data = []
|
| 186 |
except Exception:
|
|
@@ -202,6 +212,7 @@ class PostgresEngine(BaseEngine):
|
|
| 202 |
conn = self._ensure_conn()
|
| 203 |
start = time.monotonic()
|
| 204 |
try:
|
|
|
|
| 205 |
if params:
|
| 206 |
cur = await conn.execute(sql, params)
|
| 207 |
else:
|
|
@@ -209,7 +220,7 @@ class PostgresEngine(BaseEngine):
|
|
| 209 |
rows = await cur.fetchall()
|
| 210 |
columns = [desc.name for desc in cur.description] if cur.description else []
|
| 211 |
elapsed = int((time.monotonic() - start) * 1000)
|
| 212 |
-
result_rows = [dict(row) for row in rows]
|
| 213 |
return QueryResult(
|
| 214 |
columns=columns,
|
| 215 |
rows=result_rows,
|
|
@@ -232,15 +243,17 @@ class PostgresEngine(BaseEngine):
|
|
| 232 |
async def get_table_stats(self, table: str) -> TableStats:
|
| 233 |
conn = self._ensure_conn()
|
| 234 |
try:
|
| 235 |
-
|
|
|
|
| 236 |
row_count = row[0] if row else 0
|
| 237 |
except Exception:
|
| 238 |
row_count = 0
|
| 239 |
|
| 240 |
try:
|
| 241 |
-
|
| 242 |
"SELECT pg_size_pretty(pg_total_relation_size(%s))", [table]
|
| 243 |
-
)
|
|
|
|
| 244 |
total_size = row[0] if row else "unknown"
|
| 245 |
except Exception:
|
| 246 |
total_size = "unknown"
|
|
|
|
| 4 |
from mcp_database_universal.engines.base import (
|
| 5 |
BaseEngine, DBInfo, ColumnInfo, TableInfo, TableDetail,
|
| 6 |
IndexInfo, ForeignKeyInfo, TableStats, QueryResult,
|
| 7 |
+
_named_to_pyformat,
|
| 8 |
)
|
| 9 |
|
| 10 |
|
|
|
|
| 55 |
|
| 56 |
async def get_db_info(self) -> DBInfo:
|
| 57 |
conn = self._ensure_conn()
|
| 58 |
+
cur = await conn.execute("SELECT version()")
|
| 59 |
+
row = await cur.fetchone()
|
| 60 |
version = row[0] if row else "unknown"
|
| 61 |
try:
|
| 62 |
+
cur = await conn.execute(
|
| 63 |
"SELECT pg_size_pretty(pg_database_size(current_database()))"
|
| 64 |
+
)
|
| 65 |
+
row2 = await cur.fetchone()
|
| 66 |
size = row2[0] if row2 else "unknown"
|
| 67 |
except Exception:
|
| 68 |
size = "unknown"
|
|
|
|
| 70 |
|
| 71 |
async def get_tables(self) -> list[TableInfo]:
|
| 72 |
conn = self._ensure_conn()
|
| 73 |
+
cur = await conn.execute("""
|
| 74 |
SELECT t.table_name
|
| 75 |
FROM information_schema.tables t
|
| 76 |
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
| 77 |
ORDER BY t.table_name
|
| 78 |
+
""")
|
| 79 |
+
rows = await cur.fetchall()
|
| 80 |
|
| 81 |
tables = []
|
| 82 |
for row in rows:
|
| 83 |
name = row[0]
|
| 84 |
try:
|
| 85 |
+
cur = await conn.execute(f'SELECT COUNT(*) FROM "{name}"')
|
| 86 |
+
cnt = await cur.fetchone()
|
| 87 |
row_count = cnt[0] if cnt else 0
|
| 88 |
except Exception:
|
| 89 |
row_count = 0
|
| 90 |
|
| 91 |
try:
|
| 92 |
+
cur = await conn.execute("""
|
| 93 |
SELECT COUNT(*) FROM information_schema.columns
|
| 94 |
WHERE table_schema = 'public' AND table_name = %s
|
| 95 |
+
""", [name])
|
| 96 |
+
cols = await cur.fetchone()
|
| 97 |
col_count = cols[0] if cols else 0
|
| 98 |
except Exception:
|
| 99 |
col_count = 0
|
|
|
|
| 109 |
async def get_table_detail(self, table: str) -> TableDetail:
|
| 110 |
conn = self._ensure_conn()
|
| 111 |
|
| 112 |
+
cur = await conn.execute("""
|
| 113 |
SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
|
| 114 |
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk
|
| 115 |
FROM information_schema.columns c
|
|
|
|
| 122 |
) pk ON c.column_name = pk.column_name
|
| 123 |
WHERE c.table_schema = 'public' AND c.table_name = %s
|
| 124 |
ORDER BY c.ordinal_position
|
| 125 |
+
""", [table, table])
|
| 126 |
+
col_rows = await cur.fetchall()
|
| 127 |
|
| 128 |
columns = []
|
| 129 |
pk_name = None
|
|
|
|
| 139 |
is_primary_key=is_pk,
|
| 140 |
))
|
| 141 |
|
| 142 |
+
cur = await conn.execute("""
|
| 143 |
SELECT
|
| 144 |
kcu.column_name,
|
| 145 |
ccu.table_name AS foreign_table_name,
|
|
|
|
| 150 |
JOIN information_schema.constraint_column_usage ccu
|
| 151 |
ON tc.constraint_name = ccu.constraint_name
|
| 152 |
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = %s
|
| 153 |
+
""", [table])
|
| 154 |
+
fk_rows = await cur.fetchall()
|
| 155 |
|
| 156 |
foreign_keys = []
|
| 157 |
fk_cols = set()
|
|
|
|
| 172 |
col.foreign_key_column = fk.references_column
|
| 173 |
break
|
| 174 |
|
| 175 |
+
cur = await conn.execute("""
|
| 176 |
SELECT indexname, indexdef
|
| 177 |
FROM pg_indexes
|
| 178 |
WHERE tablename = %s AND schemaname = 'public'
|
| 179 |
+
""", [table])
|
| 180 |
+
idx_rows = await cur.fetchall()
|
| 181 |
|
| 182 |
indexes = []
|
| 183 |
for idx in idx_rows:
|
|
|
|
| 186 |
indexes.append(IndexInfo(name=name, columns=[], unique=unique))
|
| 187 |
|
| 188 |
try:
|
| 189 |
+
cur = await conn.execute(f'SELECT * FROM "{table}" LIMIT 5')
|
| 190 |
+
sample_rows = await cur.fetchall()
|
| 191 |
+
if sample_rows:
|
| 192 |
+
cols_d = [d.name for d in cur.description] if cur.description else []
|
| 193 |
+
sample_data = [dict(zip(cols_d, row)) for row in sample_rows]
|
| 194 |
else:
|
| 195 |
sample_data = []
|
| 196 |
except Exception:
|
|
|
|
| 212 |
conn = self._ensure_conn()
|
| 213 |
start = time.monotonic()
|
| 214 |
try:
|
| 215 |
+
sql, params = self._translate_params(sql, params)
|
| 216 |
if params:
|
| 217 |
cur = await conn.execute(sql, params)
|
| 218 |
else:
|
|
|
|
| 220 |
rows = await cur.fetchall()
|
| 221 |
columns = [desc.name for desc in cur.description] if cur.description else []
|
| 222 |
elapsed = int((time.monotonic() - start) * 1000)
|
| 223 |
+
result_rows = [dict(zip(columns, row)) for row in rows]
|
| 224 |
return QueryResult(
|
| 225 |
columns=columns,
|
| 226 |
rows=result_rows,
|
|
|
|
| 243 |
async def get_table_stats(self, table: str) -> TableStats:
|
| 244 |
conn = self._ensure_conn()
|
| 245 |
try:
|
| 246 |
+
cur = await conn.execute(f'SELECT COUNT(*) FROM "{table}"')
|
| 247 |
+
row = await cur.fetchone()
|
| 248 |
row_count = row[0] if row else 0
|
| 249 |
except Exception:
|
| 250 |
row_count = 0
|
| 251 |
|
| 252 |
try:
|
| 253 |
+
cur = await conn.execute(
|
| 254 |
"SELECT pg_size_pretty(pg_total_relation_size(%s))", [table]
|
| 255 |
+
)
|
| 256 |
+
row = await cur.fetchone()
|
| 257 |
total_size = row[0] if row else "unknown"
|
| 258 |
except Exception:
|
| 259 |
total_size = "unknown"
|
mcp_database_universal/formatters/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (211 Bytes). View file
|
|
|
mcp_database_universal/formatters/__pycache__/llm.cpython-311.pyc
ADDED
|
Binary file (17 kB). View file
|
|
|
mcp_database_universal/nl2sql.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Natural-language to SQL translation.
|
| 2 |
+
|
| 3 |
+
Two strategies:
|
| 4 |
+
|
| 5 |
+
1. ``llm_translate`` — real LLM-backed translation. Calls OpenAI or
|
| 6 |
+
Anthropic over plain HTTP (``urllib``, no extra dependencies) when an
|
| 7 |
+
API key is configured.
|
| 8 |
+
2. ``offline_translate`` — dependency-free rules-based parser that covers
|
| 9 |
+
common English question shapes against the database's real table names.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import urllib.request
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
OPENAI_URL = "https://api.openai.com/v1/chat/completions"
|
| 19 |
+
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
|
| 20 |
+
ANTHROPIC_VERSION = "2023-06-01"
|
| 21 |
+
|
| 22 |
+
DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
|
| 23 |
+
DEFAULT_ANTHROPIC_MODEL = "claude-3-5-haiku-20241022"
|
| 24 |
+
|
| 25 |
+
LLM_TIMEOUT = 25.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class Translation:
|
| 30 |
+
sql: str | None
|
| 31 |
+
source: str # "llm" or "offline"
|
| 32 |
+
error: str | None = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _build_prompt(table_names: list[str]) -> str:
|
| 36 |
+
quoted = ", ".join(f'"{t}"' for t in table_names)
|
| 37 |
+
return (
|
| 38 |
+
"You translate a user question into a single read-only SQL query. "
|
| 39 |
+
"The database contains these tables: " + quoted + ".\n\n"
|
| 40 |
+
"Rules:\n"
|
| 41 |
+
"- Output ONLY the SQL statement. No explanations, no markdown.\n"
|
| 42 |
+
"- Use SELECT (or WITH ... SELECT). Never INSERT/UPDATE/DELETE/DROP.\n"
|
| 43 |
+
"- Quote identifiers that need quoting, values must be quoted properly.\n"
|
| 44 |
+
"- Return plain text SQL ending with a newline."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _extract_sql(response: str) -> str | None:
|
| 49 |
+
text = response.strip()
|
| 50 |
+
if not text:
|
| 51 |
+
return None
|
| 52 |
+
# strip triple-backtick fences (with or without a language tag)
|
| 53 |
+
fenced = re.search(r"```(?:sql)?\s*(.*?)```", text, re.IGNORECASE | re.DOTALL)
|
| 54 |
+
if fenced:
|
| 55 |
+
text = fenced.group(1).strip()
|
| 56 |
+
else:
|
| 57 |
+
# if the model wrapped the answer in prose, grab the first statement
|
| 58 |
+
for ln in text.splitlines():
|
| 59 |
+
ln = ln.strip()
|
| 60 |
+
if re.match(r"^(SELECT|WITH)\b", ln, re.IGNORECASE):
|
| 61 |
+
text = ln
|
| 62 |
+
break
|
| 63 |
+
text = text.rstrip(";").strip()
|
| 64 |
+
if re.match(r"^(SELECT|WITH)\b", text, re.IGNORECASE):
|
| 65 |
+
return text
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _call_openai(api_key: str, prompt: str, question: str, model: str) -> str:
|
| 70 |
+
body = {
|
| 71 |
+
"model": model,
|
| 72 |
+
"messages": [
|
| 73 |
+
{"role": "system", "content": prompt},
|
| 74 |
+
{"role": "user", "content": question},
|
| 75 |
+
],
|
| 76 |
+
"temperature": 0,
|
| 77 |
+
}
|
| 78 |
+
req = urllib.request.Request(
|
| 79 |
+
OPENAI_URL,
|
| 80 |
+
data=json.dumps(body).encode("utf-8"),
|
| 81 |
+
headers={
|
| 82 |
+
"Content-Type": "application/json",
|
| 83 |
+
"Authorization": f"Bearer {api_key}",
|
| 84 |
+
},
|
| 85 |
+
method="POST",
|
| 86 |
+
)
|
| 87 |
+
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
| 88 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 89 |
+
return data["choices"][0]["message"]["content"]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _call_anthropic(api_key: str, prompt: str, question: str, model: str) -> str:
|
| 93 |
+
body = {
|
| 94 |
+
"model": model,
|
| 95 |
+
"max_tokens": 512,
|
| 96 |
+
"system": prompt,
|
| 97 |
+
"messages": [{"role": "user", "content": question}],
|
| 98 |
+
}
|
| 99 |
+
req = urllib.request.Request(
|
| 100 |
+
ANTHROPIC_URL,
|
| 101 |
+
data=json.dumps(body).encode("utf-8"),
|
| 102 |
+
headers={
|
| 103 |
+
"Content-Type": "application/json",
|
| 104 |
+
"x-api-key": api_key,
|
| 105 |
+
"anthropic-version": ANTHROPIC_VERSION,
|
| 106 |
+
},
|
| 107 |
+
method="POST",
|
| 108 |
+
)
|
| 109 |
+
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
| 110 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 111 |
+
content = data.get("content", [])
|
| 112 |
+
return "".join(block.get("text", "") for block in content)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def llm_translate(
|
| 116 |
+
question: str,
|
| 117 |
+
table_names: list[str],
|
| 118 |
+
*,
|
| 119 |
+
openai_key: str | None = None,
|
| 120 |
+
anthropic_key: str | None = None,
|
| 121 |
+
openai_model: str = DEFAULT_OPENAI_MODEL,
|
| 122 |
+
anthropic_model: str = DEFAULT_ANTHROPIC_MODEL,
|
| 123 |
+
) -> Translation:
|
| 124 |
+
"""Translate via a real LLM API call (OpenAI preferred, then Anthropic)."""
|
| 125 |
+
prompt = _build_prompt(table_names)
|
| 126 |
+
|
| 127 |
+
if openai_key:
|
| 128 |
+
try:
|
| 129 |
+
raw = await asyncio.wait_for(
|
| 130 |
+
asyncio.to_thread(_call_openai, openai_key, prompt, question, openai_model),
|
| 131 |
+
timeout=LLM_TIMEOUT,
|
| 132 |
+
)
|
| 133 |
+
sql = _extract_sql(raw)
|
| 134 |
+
if sql:
|
| 135 |
+
return Translation(sql=sql, source="llm")
|
| 136 |
+
return Translation(sql=None, source="llm", error="LLM returned no usable SQL")
|
| 137 |
+
except Exception as e: # network, timeout, bad response
|
| 138 |
+
return Translation(sql=None, source="llm", error=str(e))
|
| 139 |
+
|
| 140 |
+
if anthropic_key:
|
| 141 |
+
try:
|
| 142 |
+
raw = await asyncio.wait_for(
|
| 143 |
+
asyncio.to_thread(_call_anthropic, anthropic_key, prompt, question, anthropic_model),
|
| 144 |
+
timeout=LLM_TIMEOUT,
|
| 145 |
+
)
|
| 146 |
+
sql = _extract_sql(raw)
|
| 147 |
+
if sql:
|
| 148 |
+
return Translation(sql=sql, source="llm")
|
| 149 |
+
return Translation(sql=None, source="llm", error="LLM returned no usable SQL")
|
| 150 |
+
except Exception as e:
|
| 151 |
+
return Translation(sql=None, source="llm", error=str(e))
|
| 152 |
+
|
| 153 |
+
return Translation(sql=None, source="llm", error="No API key configured")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def offline_translate(question: str, table_names: list[str]) -> Translation:
|
| 157 |
+
"""Rule-based fallback. English-only patterns, matched against real tables."""
|
| 158 |
+
table_map = {t.lower(): t for t in table_names}
|
| 159 |
+
q = question.lower().strip()
|
| 160 |
+
|
| 161 |
+
def table_match(words: tuple[str, ...]) -> tuple[str, list[str]] | None:
|
| 162 |
+
"""Return (real_name, matched_words) if any consecutive table matches."""
|
| 163 |
+
if not words:
|
| 164 |
+
return None
|
| 165 |
+
for i in range(len(words)):
|
| 166 |
+
for size in (2, 1):
|
| 167 |
+
if i + size > len(words):
|
| 168 |
+
continue
|
| 169 |
+
chunk = " ".join(words[i : i + size])
|
| 170 |
+
candidates = {chunk}
|
| 171 |
+
folded = chunk.replace("_", "")
|
| 172 |
+
candidates.add(folded)
|
| 173 |
+
# singular/plural tolerance ("user" -> "users", "users" -> "user")
|
| 174 |
+
candidates.add(chunk + "s")
|
| 175 |
+
if size == 1 and chunk.endswith("s"):
|
| 176 |
+
candidates.add(chunk[:-1])
|
| 177 |
+
for cand in candidates:
|
| 178 |
+
if cand in table_map:
|
| 179 |
+
return table_map[cand], list(words[i : i + size])
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
words = re.findall(r"[\w_]+", q)
|
| 183 |
+
|
| 184 |
+
# "how many <table>." / "count of <table>." / "how many <table> are there"
|
| 185 |
+
m = re.search(r"\b(?:how many|count|number of)\b\s+([\w\s]+?)\s*$", q)
|
| 186 |
+
if m:
|
| 187 |
+
hit = table_match(tuple(m.group(1).split()))
|
| 188 |
+
if hit:
|
| 189 |
+
tbl = hit[0]
|
| 190 |
+
return Translation(sql=f'SELECT COUNT(*) AS count FROM "{tbl}"', source="offline")
|
| 191 |
+
|
| 192 |
+
# "top N <col> of <table>" / "highest/lowest/most/least ... <col> in <table>"
|
| 193 |
+
m = re.search(
|
| 194 |
+
r"\b(top|best|worst|highest|lowest|most expensive|least expensive|max|min)\b\s*"
|
| 195 |
+
r"(\d+)?\s*([\w_]+?)\s+(?:in|of|from)\s+([\w\s]+)",
|
| 196 |
+
q,
|
| 197 |
+
)
|
| 198 |
+
if m:
|
| 199 |
+
order, num_s, col_part, tbl_part = m.groups()
|
| 200 |
+
hit = table_match(tuple(tbl_part.split()))
|
| 201 |
+
if hit:
|
| 202 |
+
tbl = hit[0]
|
| 203 |
+
col_cand = col_part.strip()
|
| 204 |
+
limit = int(num_s) if num_s else 10
|
| 205 |
+
direction = "ASC" if order in ("lowest", "least expensive", "min") else "DESC"
|
| 206 |
+
return Translation(
|
| 207 |
+
sql=f'SELECT * FROM "{tbl}" ORDER BY "{col_cand}" {direction} LIMIT {limit}',
|
| 208 |
+
source="offline",
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# "show/list/get/select ... <col> from <table>" or "select * from <table>"
|
| 212 |
+
m = re.search(r"\bfrom\s+([\w\s]+)", q)
|
| 213 |
+
if m:
|
| 214 |
+
hit = table_match(tuple(m.group(2).split()))
|
| 215 |
+
if hit:
|
| 216 |
+
tbl = hit[0]
|
| 217 |
+
return Translation(sql=f'SELECT * FROM "{tbl}" LIMIT 100', source="offline")
|
| 218 |
+
|
| 219 |
+
# "<table> where <col> = <value>"
|
| 220 |
+
m = re.search(r"\bwhere\b\s+([\w_]+)\s*[=:]\s*['\"]?([\w\s.,%]+?)['\"]?$", q)
|
| 221 |
+
if m:
|
| 222 |
+
col_cand, val = m.groups()
|
| 223 |
+
hit = table_match(tuple(words))
|
| 224 |
+
if hit:
|
| 225 |
+
tbl = hit[0]
|
| 226 |
+
if re.fullmatch(r"\d+(\.\d+)?", val.strip()):
|
| 227 |
+
return Translation(
|
| 228 |
+
sql=f'SELECT * FROM "{tbl}" WHERE "{col_cand}" = {val.strip()} LIMIT 100',
|
| 229 |
+
source="offline",
|
| 230 |
+
)
|
| 231 |
+
return Translation(
|
| 232 |
+
sql=f'SELECT * FROM "{tbl}" WHERE "{col_cand}" = \'{val.strip()}\' LIMIT 100',
|
| 233 |
+
source="offline",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# table mentioned at all → return the table as a fallback listing
|
| 237 |
+
if words:
|
| 238 |
+
hit = table_match(tuple(words))
|
| 239 |
+
if hit:
|
| 240 |
+
tbl = hit[0]
|
| 241 |
+
return Translation(sql=f'SELECT * FROM "{tbl}" LIMIT 100', source="offline")
|
| 242 |
+
|
| 243 |
+
return Translation(sql=None, source="offline")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
async def translate(
|
| 247 |
+
question: str,
|
| 248 |
+
table_names: list[str],
|
| 249 |
+
*,
|
| 250 |
+
openai_key: str | None = None,
|
| 251 |
+
anthropic_key: str | None = None,
|
| 252 |
+
) -> Translation:
|
| 253 |
+
"""Prefer the LLM path when a key is available, otherwise use the parser."""
|
| 254 |
+
llm = await llm_translate(
|
| 255 |
+
question,
|
| 256 |
+
table_names,
|
| 257 |
+
openai_key=openai_key,
|
| 258 |
+
anthropic_key=anthropic_key,
|
| 259 |
+
)
|
| 260 |
+
if llm.sql:
|
| 261 |
+
return llm
|
| 262 |
+
|
| 263 |
+
offline = offline_translate(question, table_names)
|
| 264 |
+
if offline.sql:
|
| 265 |
+
return offline
|
| 266 |
+
|
| 267 |
+
if llm.error and llm.error != "No API key configured":
|
| 268 |
+
return Translation(sql=None, source="llm", error=llm.error)
|
| 269 |
+
return Translation(sql=None, source="offline")
|
mcp_database_universal/server.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
"""MCP Database Server — main server with 7 reasoning tools."""
|
| 2 |
|
| 3 |
import json
|
| 4 |
-
import re
|
| 5 |
import sys
|
| 6 |
import logging
|
| 7 |
from mcp.server.mcpserver import MCPServer
|
|
@@ -10,6 +9,8 @@ from mcp_database_universal.engines.base import BaseEngine
|
|
| 10 |
from mcp_database_universal.safety import SafetyValidator
|
| 11 |
from mcp_database_universal.schema_inspector import SchemaInspector
|
| 12 |
from mcp_database_universal.formatters.llm import LLMFormatter
|
|
|
|
|
|
|
| 13 |
|
| 14 |
logger = logging.getLogger("mcp-db")
|
| 15 |
|
|
@@ -17,7 +18,7 @@ logger = logging.getLogger("mcp-db")
|
|
| 17 |
async def create_server(config: DatabaseConfig, engine: BaseEngine) -> MCPServer:
|
| 18 |
server = MCPServer(
|
| 19 |
name="mcp-database-server",
|
| 20 |
-
version=
|
| 21 |
)
|
| 22 |
|
| 23 |
safety = SafetyValidator(
|
|
@@ -118,51 +119,30 @@ async def create_server(config: DatabaseConfig, engine: BaseEngine) -> MCPServer
|
|
| 118 |
about the data. Examples: "How many users have orders?",
|
| 119 |
"What product sells the best?"
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
Returns: generated SQL + results + explanation.
|
| 122 |
"""
|
| 123 |
-
question_lower = question.lower().strip()
|
| 124 |
tables = await engine.get_tables()
|
| 125 |
table_names = [t.name for t in tables]
|
| 126 |
-
table_map = {t.name.lower(): t.name for t in tables}
|
| 127 |
-
|
| 128 |
-
sql = None
|
| 129 |
-
|
| 130 |
-
count_match = re.search(r'(?:kolik|count|how many)\s+(\w+)', question_lower)
|
| 131 |
-
if count_match:
|
| 132 |
-
candidate = count_match.group(1)
|
| 133 |
-
if candidate in table_map:
|
| 134 |
-
real_name = table_map[candidate]
|
| 135 |
-
sql = f'SELECT COUNT(*) as count FROM "{real_name}"'
|
| 136 |
-
|
| 137 |
-
if not sql:
|
| 138 |
-
select_match = re.search(r'(?:vsechny|zobraz|ukaž|show|select|get)\s+(\w+)', question_lower)
|
| 139 |
-
if select_match:
|
| 140 |
-
candidate = select_match.group(1)
|
| 141 |
-
if candidate in table_map:
|
| 142 |
-
real_name = table_map[candidate]
|
| 143 |
-
sql = f'SELECT * FROM "{real_name}" LIMIT 100'
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
sql = f'SELECT * FROM "{real_name}" WHERE "{col_cand}" = ? LIMIT 100'
|
| 152 |
-
|
| 153 |
-
if not sql:
|
| 154 |
-
top_match = re.search(r'nej(?:vetsi|mensi|lepsi|drazsi|levnejsi|best|worst|top)\s+(\w+)\s+v\s+(\w+)', question_lower)
|
| 155 |
-
if not top_match:
|
| 156 |
-
top_match = re.search(r'(?:top|best|worst|highest|lowest)\s+(\w+)\s+(?:in|from)\s+(\w+)', question_lower)
|
| 157 |
-
if top_match:
|
| 158 |
-
col_cand, table_cand = top_match.groups()
|
| 159 |
-
if table_cand in table_map:
|
| 160 |
-
real_name = table_map[table_cand]
|
| 161 |
-
sql = f'SELECT * FROM "{real_name}" ORDER BY "{col_cand}" DESC LIMIT 10'
|
| 162 |
|
|
|
|
| 163 |
if not sql:
|
|
|
|
|
|
|
| 164 |
return (
|
| 165 |
-
"Could not automatically translate your question to SQL
|
|
|
|
|
|
|
| 166 |
"Try using the `query` tool directly with SQL, or rephrase your question.\n"
|
| 167 |
f"Available tables: {', '.join(table_names)}\n\n"
|
| 168 |
"Examples:\n"
|
|
@@ -171,13 +151,15 @@ async def create_server(config: DatabaseConfig, engine: BaseEngine) -> MCPServer
|
|
| 171 |
"- 'What products cost more than 100?'"
|
| 172 |
)
|
| 173 |
|
| 174 |
-
|
|
|
|
| 175 |
if not validation.approved:
|
| 176 |
return f"Generated query was blocked: {validation.reason}"
|
| 177 |
|
| 178 |
-
result = await engine.execute_query(
|
| 179 |
output = formatter.format_query_result(result)
|
| 180 |
-
|
|
|
|
| 181 |
return output
|
| 182 |
|
| 183 |
@server.tool()
|
|
|
|
| 1 |
"""MCP Database Server — main server with 7 reasoning tools."""
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import sys
|
| 5 |
import logging
|
| 6 |
from mcp.server.mcpserver import MCPServer
|
|
|
|
| 9 |
from mcp_database_universal.safety import SafetyValidator
|
| 10 |
from mcp_database_universal.schema_inspector import SchemaInspector
|
| 11 |
from mcp_database_universal.formatters.llm import LLMFormatter
|
| 12 |
+
from mcp_database_universal.nl2sql import translate
|
| 13 |
+
from mcp_database_universal import __version__
|
| 14 |
|
| 15 |
logger = logging.getLogger("mcp-db")
|
| 16 |
|
|
|
|
| 18 |
async def create_server(config: DatabaseConfig, engine: BaseEngine) -> MCPServer:
|
| 19 |
server = MCPServer(
|
| 20 |
name="mcp-database-server",
|
| 21 |
+
version=__version__,
|
| 22 |
)
|
| 23 |
|
| 24 |
safety = SafetyValidator(
|
|
|
|
| 119 |
about the data. Examples: "How many users have orders?",
|
| 120 |
"What product sells the best?"
|
| 121 |
|
| 122 |
+
Translation: an LLM (OpenAI/Anthropic) is used when OPENAI_API_KEY or
|
| 123 |
+
ANTHROPIC_API_KEY is configured; otherwise a built-in rules-based
|
| 124 |
+
parser handles common English question shapes.
|
| 125 |
+
|
| 126 |
Returns: generated SQL + results + explanation.
|
| 127 |
"""
|
|
|
|
| 128 |
tables = await engine.get_tables()
|
| 129 |
table_names = [t.name for t in tables]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
translation = await translate(
|
| 132 |
+
question,
|
| 133 |
+
table_names,
|
| 134 |
+
openai_key=config.openai_key,
|
| 135 |
+
anthropic_key=config.anthropic_key,
|
| 136 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
sql = translation.sql
|
| 139 |
if not sql:
|
| 140 |
+
reason = translation.error or "no matching pattern"
|
| 141 |
+
source_note = f" (LLM: {reason})" if translation.source == "llm" else ""
|
| 142 |
return (
|
| 143 |
+
"Could not automatically translate your question to SQL"
|
| 144 |
+
+ source_note
|
| 145 |
+
+ ".\n\n"
|
| 146 |
"Try using the `query` tool directly with SQL, or rephrase your question.\n"
|
| 147 |
f"Available tables: {', '.join(table_names)}\n\n"
|
| 148 |
"Examples:\n"
|
|
|
|
| 151 |
"- 'What products cost more than 100?'"
|
| 152 |
)
|
| 153 |
|
| 154 |
+
safe_sql = safety.ensure_limit(sql)
|
| 155 |
+
validation = safety.validate(safe_sql)
|
| 156 |
if not validation.approved:
|
| 157 |
return f"Generated query was blocked: {validation.reason}"
|
| 158 |
|
| 159 |
+
result = await engine.execute_query(safe_sql)
|
| 160 |
output = formatter.format_query_result(result)
|
| 161 |
+
source_note = "LLM" if translation.source == "llm" else "rules-based"
|
| 162 |
+
output = f"**Question:** {question}\n**Generated SQL** ({source_note}): `{safe_sql}`\n\n{output}"
|
| 163 |
return output
|
| 164 |
|
| 165 |
@server.tool()
|