Lukynnnn commited on
Commit
93166d0
·
verified ·
1 Parent(s): 71e58d3

Upload folder using huggingface_hub

Browse files
mcp_database_universal/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
  """MCP Database Server — Reasoning interface for databases."""
2
 
3
- __version__ = "0.1.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
- row = await conn.execute("SELECT version()").fetchone()
 
58
  version = row[0] if row else "unknown"
59
  try:
60
- row2 = await conn.execute(
61
  "SELECT pg_size_pretty(pg_database_size(current_database()))"
62
- ).fetchone()
 
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
- rows = await conn.execute("""
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
- """).fetchall()
 
76
 
77
  tables = []
78
  for row in rows:
79
  name = row[0]
80
  try:
81
- cnt = await conn.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()
 
82
  row_count = cnt[0] if cnt else 0
83
  except Exception:
84
  row_count = 0
85
 
86
  try:
87
- cols = await conn.execute("""
88
  SELECT COUNT(*) FROM information_schema.columns
89
  WHERE table_schema = 'public' AND table_name = %s
90
- """, [name]).fetchone()
 
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
- col_rows = await conn.execute("""
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]).fetchall()
 
120
 
121
  columns = []
122
  pk_name = None
@@ -132,7 +139,7 @@ class PostgresEngine(BaseEngine):
132
  is_primary_key=is_pk,
133
  ))
134
 
135
- fk_rows = await conn.execute("""
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]).fetchall()
 
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
- idx_rows = await conn.execute("""
168
  SELECT indexname, indexdef
169
  FROM pg_indexes
170
  WHERE tablename = %s AND schemaname = 'public'
171
- """, [table]).fetchall()
 
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
- sample = await conn.execute(f'SELECT * FROM "{table}" LIMIT 5').fetchall()
181
- if sample:
182
- cols_desc = sample[0].keys() if hasattr(sample[0], 'keys') else []
183
- sample_data = [dict(row) for row in sample]
 
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
- row = await conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()
 
236
  row_count = row[0] if row else 0
237
  except Exception:
238
  row_count = 0
239
 
240
  try:
241
- row = await conn.execute(
242
  "SELECT pg_size_pretty(pg_total_relation_size(%s))", [table]
243
- ).fetchone()
 
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="0.1.0",
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
- if not sql:
146
- where_match = re.search(r'(\w+)\s+(?:s|where|with)\s+(\w+)\s*[=:]\s*["\']?(\w+)["\']?', question_lower)
147
- if where_match:
148
- table_cand, col_cand, val = where_match.groups()
149
- if table_cand in table_map:
150
- real_name = table_map[table_cand]
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.\n\n"
 
 
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
- validation = safety.validate(sql)
 
175
  if not validation.approved:
176
  return f"Generated query was blocked: {validation.reason}"
177
 
178
- result = await engine.execute_query(sql)
179
  output = formatter.format_query_result(result)
180
- output = f"**Question:** {question}\n\n{output}"
 
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()