sql-query-mcp 0.1.4__tar.gz → 0.2.0__tar.gz

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.
Files changed (37) hide show
  1. {sql_query_mcp-0.1.4/sql_query_mcp.egg-info → sql_query_mcp-0.2.0}/PKG-INFO +16 -8
  2. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/README.md +14 -7
  3. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/pyproject.toml +2 -1
  4. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/mysql.py +8 -0
  5. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/postgres.py +10 -0
  6. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/app.py +24 -0
  7. sql_query_mcp-0.2.0/sql_query_mcp/importer.py +223 -0
  8. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0/sql_query_mcp.egg-info}/PKG-INFO +16 -8
  9. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/SOURCES.txt +3 -0
  10. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/requires.txt +1 -0
  11. sql_query_mcp-0.2.0/tests/test_app.py +19 -0
  12. sql_query_mcp-0.2.0/tests/test_importer.py +330 -0
  13. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_validator.py +20 -0
  14. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/LICENSE +0 -0
  15. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/setup.cfg +0 -0
  16. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/__init__.py +0 -0
  17. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/__main__.py +0 -0
  18. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/__init__.py +0 -0
  19. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/audit.py +0 -0
  20. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/config.py +0 -0
  21. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/errors.py +0 -0
  22. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/executor.py +0 -0
  23. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/introspection.py +0 -0
  24. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/namespace.py +0 -0
  25. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/registry.py +0 -0
  26. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/release_metadata.py +0 -0
  27. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/validator.py +0 -0
  28. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/dependency_links.txt +0 -0
  29. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/entry_points.txt +0 -0
  30. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/top_level.txt +0 -0
  31. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_audit.py +0 -0
  32. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_config.py +0 -0
  33. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_executor.py +0 -0
  34. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_metadata.py +0 -0
  35. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_namespace.py +0 -0
  36. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_registry.py +0 -0
  37. {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_release_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-query-mcp
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Read-only SQL MCP server for PostgreSQL and MySQL.
5
5
  Author: Andy Wang
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: mcp>=1.12.4
24
+ Requires-Dist: openpyxl>=3.1
24
25
  Requires-Dist: PyMySQL>=1.1
25
26
  Requires-Dist: psycopg[binary]>=3.2
26
27
  Requires-Dist: psycopg-pool>=3.2
@@ -35,6 +36,8 @@ Dynamic: license-file
35
36
  A general-purpose MCP server that lets AI work with multiple databases within
36
37
  clear boundaries.
37
38
 
39
+ [![sql-query-mcp MCP server](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp/badges/card.svg)](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
40
+
38
41
  ## Current database support
39
42
 
40
43
  | Database | Status | Current availability |
@@ -56,9 +59,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
56
59
 
57
60
  ## What AI can do with it
58
61
 
59
- The current tool set focuses on database discovery and controlled query
60
- workflows. You can use it to help an AI assistant understand structure before
61
- it generates or refines SQL.
62
+ The current tool set focuses on database discovery, controlled query workflows,
63
+ and one narrow local file import path. You can use it to help an AI assistant
64
+ understand structure before it generates SQL or imports a prepared CSV/XLSX file
65
+ into an existing table.
62
66
 
63
67
  MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
64
68
  the current implementation.
@@ -73,16 +77,18 @@ the current implementation.
73
77
  | `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
74
78
  | `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
75
79
  | `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
80
+ | `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
76
81
 
77
82
  These tools are useful for tasks such as listing namespaces, inspecting table
78
- definitions, reviewing indexes, sampling records, and analyzing read-only
79
- queries with `EXPLAIN`. For full request and response details, see
80
- `docs/api-reference.md` (Chinese).
83
+ definitions, reviewing indexes, sampling records, analyzing read-only queries
84
+ with `EXPLAIN`, and importing prepared local files. For full request and
85
+ response details, see `docs/api-reference.md` (Chinese).
81
86
 
82
87
  ## How boundaries are constrained
83
88
 
84
89
  The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
85
- are available today, and the current tool set is fully read-only.
90
+ are available today. Query tools remain read-only, and the only write path is a
91
+ controlled local CSV/XLSX import into existing tables.
86
92
 
87
93
  The service keeps those boundaries explicit in a few ways.
88
94
 
@@ -96,6 +102,8 @@ The service keeps those boundaries explicit in a few ways.
96
102
  database.
97
103
  - The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
98
104
  multi-statement input, and records audit logs for each call.
105
+ - `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
106
+ headers exactly match existing table columns.
99
107
 
100
108
  For MySQL, `explain_query(..., analyze=True)` is not available in the current
101
109
  implementation.
@@ -5,6 +5,8 @@
5
5
  A general-purpose MCP server that lets AI work with multiple databases within
6
6
  clear boundaries.
7
7
 
8
+ [![sql-query-mcp MCP server](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp/badges/card.svg)](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
9
+
8
10
  ## Current database support
9
11
 
10
12
  | Database | Status | Current availability |
@@ -26,9 +28,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
26
28
 
27
29
  ## What AI can do with it
28
30
 
29
- The current tool set focuses on database discovery and controlled query
30
- workflows. You can use it to help an AI assistant understand structure before
31
- it generates or refines SQL.
31
+ The current tool set focuses on database discovery, controlled query workflows,
32
+ and one narrow local file import path. You can use it to help an AI assistant
33
+ understand structure before it generates SQL or imports a prepared CSV/XLSX file
34
+ into an existing table.
32
35
 
33
36
  MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
34
37
  the current implementation.
@@ -43,16 +46,18 @@ the current implementation.
43
46
  | `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
44
47
  | `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
45
48
  | `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
49
+ | `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
46
50
 
47
51
  These tools are useful for tasks such as listing namespaces, inspecting table
48
- definitions, reviewing indexes, sampling records, and analyzing read-only
49
- queries with `EXPLAIN`. For full request and response details, see
50
- `docs/api-reference.md` (Chinese).
52
+ definitions, reviewing indexes, sampling records, analyzing read-only queries
53
+ with `EXPLAIN`, and importing prepared local files. For full request and
54
+ response details, see `docs/api-reference.md` (Chinese).
51
55
 
52
56
  ## How boundaries are constrained
53
57
 
54
58
  The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
55
- are available today, and the current tool set is fully read-only.
59
+ are available today. Query tools remain read-only, and the only write path is a
60
+ controlled local CSV/XLSX import into existing tables.
56
61
 
57
62
  The service keeps those boundaries explicit in a few ways.
58
63
 
@@ -66,6 +71,8 @@ The service keeps those boundaries explicit in a few ways.
66
71
  database.
67
72
  - The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
68
73
  multi-statement input, and records audit logs for each call.
74
+ - `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
75
+ headers exactly match existing table columns.
69
76
 
70
77
  For MySQL, `explain_query(..., analyze=True)` is not available in the current
71
78
  implementation.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sql-query-mcp"
7
- version = "0.1.4"
7
+ version = "0.2.0"
8
8
  description = "Read-only SQL MCP server for PostgreSQL and MySQL."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "mcp>=1.12.4",
27
+ "openpyxl>=3.1",
27
28
  "PyMySQL>=1.1",
28
29
  "psycopg[binary]>=3.2",
29
30
  "psycopg-pool>=3.2",
@@ -115,6 +115,14 @@ class MySQLAdapter:
115
115
  f"{self._quote_identifier(table_name)} LIMIT {int(sentinel_limit)}"
116
116
  )
117
117
 
118
+ def build_insert_query(self, database: str, table_name: str, columns: List[str]) -> str:
119
+ quoted_columns = ", ".join(self._quote_identifier(column) for column in columns)
120
+ placeholders = ", ".join(["%s"] * len(columns))
121
+ return (
122
+ f"INSERT INTO {self._quote_identifier(database)}."
123
+ f"{self._quote_identifier(table_name)} ({quoted_columns}) VALUES ({placeholders})"
124
+ )
125
+
118
126
  def build_explain_query(self, sql_text: str, analyze: bool = False) -> str:
119
127
  if analyze:
120
128
  raise SecurityError("MySQL 首版不支持 analyze=True。")
@@ -155,6 +155,16 @@ class PostgresAdapter:
155
155
  sql.Literal(sentinel_limit),
156
156
  )
157
157
 
158
+ def build_insert_query(self, schema: str, table_name: str, columns: List[str]):
159
+ if sql is None:
160
+ raise ConfigurationError("缺少 psycopg 依赖,请先安装项目依赖。")
161
+ return sql.SQL("INSERT INTO {}.{} ({}) VALUES ({})").format(
162
+ sql.Identifier(schema),
163
+ sql.Identifier(table_name),
164
+ sql.SQL(", ").join(sql.Identifier(column) for column in columns),
165
+ sql.SQL(", ").join(sql.Placeholder() for _ in columns),
166
+ )
167
+
158
168
  def build_explain_query(self, sql_text: str, analyze: bool = False) -> str:
159
169
  return f"EXPLAIN (FORMAT JSON, ANALYZE {'TRUE' if analyze else 'FALSE'}) {sql_text}"
160
170
 
@@ -10,6 +10,7 @@ from .audit import AuditLogger
10
10
  from .config import load_config
11
11
  from .errors import SqlQueryMCPError
12
12
  from .executor import QueryExecutor
13
+ from .importer import TableFileImporter
13
14
  from .introspection import MetadataService
14
15
  from .registry import ConnectionRegistry
15
16
 
@@ -20,6 +21,7 @@ def create_app() -> FastMCP:
20
21
  audit_logger = AuditLogger(app_config.settings.audit_log_path)
21
22
  metadata = MetadataService(registry, app_config.settings, audit_logger)
22
23
  executor = QueryExecutor(registry, app_config.settings, audit_logger)
24
+ importer = TableFileImporter(registry, app_config.settings, audit_logger)
23
25
 
24
26
  mcp = FastMCP("sql-query-mcp", json_response=True)
25
27
 
@@ -86,6 +88,28 @@ def create_app() -> FastMCP:
86
88
 
87
89
  return _run_tool(lambda: executor.get_table_sample(connection_id, table_name, schema, database, limit))
88
90
 
91
+ @mcp.tool()
92
+ def import_table_file(
93
+ connection_id: str,
94
+ table_name: str,
95
+ file_path: str,
96
+ schema: Optional[str] = None,
97
+ database: Optional[str] = None,
98
+ sheet_name: Optional[str] = None,
99
+ ) -> dict:
100
+ """Import a local CSV or XLSX file into an existing table."""
101
+
102
+ return _run_tool(
103
+ lambda: importer.import_table_file(
104
+ connection_id,
105
+ table_name,
106
+ file_path,
107
+ schema,
108
+ database,
109
+ sheet_name,
110
+ )
111
+ )
112
+
89
113
  return mcp
90
114
 
91
115
 
@@ -0,0 +1,223 @@
1
+ """Controlled local file imports into existing tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
9
+
10
+ try:
11
+ from openpyxl import load_workbook
12
+ except ImportError: # pragma: no cover - runtime dependency
13
+ load_workbook = None
14
+
15
+ from .audit import AuditLogger
16
+ from .config import ServerSettings
17
+ from .errors import QueryExecutionError, sanitize_error_message
18
+ from .namespace import NamespaceSelection, resolve_namespace
19
+
20
+
21
+ class TableFileImporter:
22
+ """Import CSV and XLSX files through a constrained table-only path."""
23
+
24
+ def __init__(
25
+ self,
26
+ registry: Any,
27
+ settings: ServerSettings,
28
+ audit_logger: AuditLogger,
29
+ ):
30
+ self._registry = registry
31
+ self._settings = settings
32
+ self._audit = audit_logger
33
+
34
+ def import_table_file(
35
+ self,
36
+ connection_id: str,
37
+ table_name: str,
38
+ file_path: str,
39
+ schema: Optional[str] = None,
40
+ database: Optional[str] = None,
41
+ sheet_name: Optional[str] = None,
42
+ ) -> Dict[str, object]:
43
+ started = time.perf_counter()
44
+ config = None
45
+ namespace = None
46
+ file_extension = Path(file_path).suffix.lower()
47
+ selected_sheet_name = None
48
+ inserted_row_count = 0
49
+
50
+ try:
51
+ config = self._registry.get_connection_config(connection_id)
52
+ namespace = resolve_namespace(config, schema=schema, database=database)
53
+ headers, rows, selected_sheet_name = _read_file(Path(file_path), sheet_name)
54
+ if not rows:
55
+ raise QueryExecutionError("文件没有可导入的数据行。")
56
+
57
+ with self._registry.connection_from_config(config) as (conn, adapter):
58
+ _apply_statement_timeout(adapter, conn, self._settings.statement_timeout_ms)
59
+ description = adapter.describe_table(conn, namespace.value, table_name)
60
+ if not description:
61
+ raise QueryExecutionError(
62
+ f"未找到表 {namespace.value}.{table_name},或当前用户没有访问权限"
63
+ )
64
+ table_columns = [item["column_name"] for item in description["columns"]]
65
+ _validate_headers(headers, table_columns)
66
+ query = adapter.build_insert_query(namespace.value, table_name, headers)
67
+ _execute_insert(conn, config.engine, query, rows)
68
+
69
+ inserted_row_count = len(rows)
70
+ duration_ms = _elapsed_ms(started)
71
+ self._audit.log(
72
+ tool="import_table_file",
73
+ connection_id=connection_id,
74
+ success=True,
75
+ duration_ms=duration_ms,
76
+ row_count=inserted_row_count,
77
+ extra=_build_audit_extra(
78
+ config,
79
+ namespace,
80
+ table_name,
81
+ file_extension,
82
+ selected_sheet_name,
83
+ ),
84
+ )
85
+ return {
86
+ "connection_id": connection_id,
87
+ "engine": config.engine,
88
+ namespace.field_name: namespace.value,
89
+ "table_name": table_name,
90
+ "inserted_row_count": inserted_row_count,
91
+ "duration_ms": duration_ms,
92
+ "file_extension": file_extension,
93
+ "sheet_name": selected_sheet_name,
94
+ }
95
+ except Exception as exc:
96
+ duration_ms = _elapsed_ms(started)
97
+ sanitized = sanitize_error_message(str(exc))
98
+ self._audit.log(
99
+ tool="import_table_file",
100
+ connection_id=connection_id,
101
+ success=False,
102
+ duration_ms=duration_ms,
103
+ row_count=inserted_row_count,
104
+ error=sanitized,
105
+ extra=_build_audit_extra(
106
+ config,
107
+ namespace,
108
+ table_name,
109
+ file_extension,
110
+ selected_sheet_name,
111
+ ),
112
+ )
113
+ raise QueryExecutionError(sanitized) from exc
114
+
115
+
116
+ def _read_file(path: Path, sheet_name: Optional[str]) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
117
+ extension = path.suffix.lower()
118
+ if extension == ".csv":
119
+ if sheet_name:
120
+ raise QueryExecutionError("CSV 文件不支持 sheet_name 参数。")
121
+ return _read_csv(path)
122
+ if extension == ".xlsx":
123
+ return _read_xlsx(path, sheet_name)
124
+ raise QueryExecutionError("仅支持 .csv 和 .xlsx 文件导入。")
125
+
126
+
127
+ def _read_csv(path: Path) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
128
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
129
+ reader = csv.reader(handle)
130
+ try:
131
+ headers = next(reader)
132
+ except StopIteration as exc:
133
+ raise QueryExecutionError("文件表头不能为空。") from exc
134
+ rows = [_normalize_row(row, len(headers)) for row in reader]
135
+ return headers, rows, None
136
+
137
+
138
+ def _read_xlsx(path: Path, sheet_name: Optional[str]) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
139
+ if load_workbook is None:
140
+ raise QueryExecutionError("缺少 openpyxl 依赖,请先安装项目依赖。")
141
+ workbook = load_workbook(path, read_only=True, data_only=True)
142
+ try:
143
+ if sheet_name:
144
+ if sheet_name not in workbook.sheetnames:
145
+ raise QueryExecutionError(f"XLSX 文件中不存在 sheet: {sheet_name}")
146
+ worksheet = workbook[sheet_name]
147
+ else:
148
+ worksheet = workbook.worksheets[0]
149
+ rows_iter = worksheet.iter_rows(values_only=True)
150
+ try:
151
+ header_row = next(rows_iter)
152
+ except StopIteration as exc:
153
+ raise QueryExecutionError("文件表头不能为空。") from exc
154
+ headers = ["" if value is None else str(value) for value in header_row]
155
+ rows = [_normalize_row(list(row), len(headers)) for row in rows_iter]
156
+ return headers, rows, worksheet.title
157
+ finally:
158
+ workbook.close()
159
+
160
+
161
+ def _normalize_row(row: Sequence[object], expected_length: int) -> Tuple[object, ...]:
162
+ if len(row) != expected_length:
163
+ raise QueryExecutionError("数据行字段数量必须和表头数量一致。")
164
+ return tuple(None if value == "" else value for value in row)
165
+
166
+
167
+ def _validate_headers(headers: Sequence[str], table_columns: Sequence[str]) -> None:
168
+ if not headers:
169
+ raise QueryExecutionError("文件表头不能为空。")
170
+ empty_headers = [index + 1 for index, header in enumerate(headers) if not header]
171
+ if empty_headers:
172
+ raise QueryExecutionError(f"文件表头存在空字段,位置: {empty_headers}")
173
+ duplicates = sorted({header for header in headers if headers.count(header) > 1})
174
+ if duplicates:
175
+ raise QueryExecutionError(f"文件表头存在重复字段: {duplicates}")
176
+ unknown = sorted(set(headers) - set(table_columns))
177
+ if unknown:
178
+ raise QueryExecutionError(f"文件表头包含目标表不存在的字段: {unknown}")
179
+
180
+
181
+ def _execute_insert(conn: Any, engine: str, query: object, rows: List[Tuple[object, ...]]) -> None:
182
+ if engine == "postgres" and hasattr(conn, "transaction"):
183
+ with conn.transaction():
184
+ with conn.cursor() as cur:
185
+ cur.executemany(query, rows)
186
+ return
187
+
188
+ conn.begin()
189
+ try:
190
+ with conn.cursor() as cur:
191
+ cur.executemany(query, rows)
192
+ conn.commit()
193
+ except Exception:
194
+ conn.rollback()
195
+ raise
196
+
197
+
198
+ def _elapsed_ms(started: float) -> int:
199
+ return int((time.perf_counter() - started) * 1000)
200
+
201
+
202
+ def _apply_statement_timeout(adapter: Any, conn: Any, timeout_ms: Optional[int]) -> None:
203
+ if timeout_ms is not None:
204
+ getattr(adapter, "set_statement_timeout")(conn, timeout_ms)
205
+
206
+
207
+ def _build_audit_extra(
208
+ config: Any,
209
+ namespace: Optional[NamespaceSelection],
210
+ table_name: str,
211
+ file_extension: str,
212
+ sheet_name: Optional[str],
213
+ ) -> Dict[str, object]:
214
+ extra: Dict[str, object] = {
215
+ "table_name": table_name,
216
+ "file_extension": file_extension,
217
+ "sheet_name": sheet_name,
218
+ }
219
+ if config is not None:
220
+ extra["engine"] = config.engine
221
+ if namespace is not None:
222
+ extra[namespace.field_name] = namespace.value
223
+ return extra
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-query-mcp
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Read-only SQL MCP server for PostgreSQL and MySQL.
5
5
  Author: Andy Wang
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: mcp>=1.12.4
24
+ Requires-Dist: openpyxl>=3.1
24
25
  Requires-Dist: PyMySQL>=1.1
25
26
  Requires-Dist: psycopg[binary]>=3.2
26
27
  Requires-Dist: psycopg-pool>=3.2
@@ -35,6 +36,8 @@ Dynamic: license-file
35
36
  A general-purpose MCP server that lets AI work with multiple databases within
36
37
  clear boundaries.
37
38
 
39
+ [![sql-query-mcp MCP server](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp/badges/card.svg)](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
40
+
38
41
  ## Current database support
39
42
 
40
43
  | Database | Status | Current availability |
@@ -56,9 +59,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
56
59
 
57
60
  ## What AI can do with it
58
61
 
59
- The current tool set focuses on database discovery and controlled query
60
- workflows. You can use it to help an AI assistant understand structure before
61
- it generates or refines SQL.
62
+ The current tool set focuses on database discovery, controlled query workflows,
63
+ and one narrow local file import path. You can use it to help an AI assistant
64
+ understand structure before it generates SQL or imports a prepared CSV/XLSX file
65
+ into an existing table.
62
66
 
63
67
  MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
64
68
  the current implementation.
@@ -73,16 +77,18 @@ the current implementation.
73
77
  | `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
74
78
  | `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
75
79
  | `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
80
+ | `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
76
81
 
77
82
  These tools are useful for tasks such as listing namespaces, inspecting table
78
- definitions, reviewing indexes, sampling records, and analyzing read-only
79
- queries with `EXPLAIN`. For full request and response details, see
80
- `docs/api-reference.md` (Chinese).
83
+ definitions, reviewing indexes, sampling records, analyzing read-only queries
84
+ with `EXPLAIN`, and importing prepared local files. For full request and
85
+ response details, see `docs/api-reference.md` (Chinese).
81
86
 
82
87
  ## How boundaries are constrained
83
88
 
84
89
  The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
85
- are available today, and the current tool set is fully read-only.
90
+ are available today. Query tools remain read-only, and the only write path is a
91
+ controlled local CSV/XLSX import into existing tables.
86
92
 
87
93
  The service keeps those boundaries explicit in a few ways.
88
94
 
@@ -96,6 +102,8 @@ The service keeps those boundaries explicit in a few ways.
96
102
  database.
97
103
  - The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
98
104
  multi-statement input, and records audit logs for each call.
105
+ - `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
106
+ headers exactly match existing table columns.
99
107
 
100
108
  For MySQL, `explain_query(..., analyze=True)` is not available in the current
101
109
  implementation.
@@ -8,6 +8,7 @@ sql_query_mcp/audit.py
8
8
  sql_query_mcp/config.py
9
9
  sql_query_mcp/errors.py
10
10
  sql_query_mcp/executor.py
11
+ sql_query_mcp/importer.py
11
12
  sql_query_mcp/introspection.py
12
13
  sql_query_mcp/namespace.py
13
14
  sql_query_mcp/registry.py
@@ -22,9 +23,11 @@ sql_query_mcp.egg-info/top_level.txt
22
23
  sql_query_mcp/adapters/__init__.py
23
24
  sql_query_mcp/adapters/mysql.py
24
25
  sql_query_mcp/adapters/postgres.py
26
+ tests/test_app.py
25
27
  tests/test_audit.py
26
28
  tests/test_config.py
27
29
  tests/test_executor.py
30
+ tests/test_importer.py
28
31
  tests/test_metadata.py
29
32
  tests/test_namespace.py
30
33
  tests/test_registry.py
@@ -1,4 +1,5 @@
1
1
  mcp>=1.12.4
2
+ openpyxl>=3.1
2
3
  PyMySQL>=1.1
3
4
  psycopg[binary]>=3.2
4
5
  psycopg-pool>=3.2
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import unittest
5
+
6
+ from sql_query_mcp.app import create_app
7
+
8
+
9
+ class AppTestCase(unittest.TestCase):
10
+ def test_create_app_registers_import_table_file_tool(self) -> None:
11
+ app = create_app()
12
+
13
+ tools = asyncio.run(app.list_tools())
14
+
15
+ self.assertIn("import_table_file", {tool.name for tool in tools})
16
+
17
+
18
+ if __name__ == "__main__":
19
+ unittest.main()
@@ -0,0 +1,330 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import tempfile
6
+ import unittest
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ from openpyxl import Workbook
11
+
12
+ from sql_query_mcp.audit import AuditLogger
13
+ from sql_query_mcp.config import ConnectionConfig, ServerSettings
14
+ from sql_query_mcp.errors import QueryExecutionError
15
+ from sql_query_mcp.importer import TableFileImporter
16
+
17
+
18
+ class _CursorStub:
19
+ def __init__(self, error: Exception | None = None) -> None:
20
+ self._error = error
21
+ self.executed_many = []
22
+
23
+ def __enter__(self):
24
+ return self
25
+
26
+ def __exit__(self, exc_type, exc, tb) -> None:
27
+ return None
28
+
29
+ def executemany(self, query, rows) -> None:
30
+ if self._error is not None:
31
+ raise self._error
32
+ self.executed_many.append((query, rows))
33
+
34
+
35
+ class _ConnectionStub:
36
+ def __init__(self, error: Exception | None = None) -> None:
37
+ self.cursor_stub = _CursorStub(error)
38
+ self.begin_calls = 0
39
+ self.commit_calls = 0
40
+ self.rollback_calls = 0
41
+
42
+ def cursor(self) -> _CursorStub:
43
+ return self.cursor_stub
44
+
45
+ def begin(self) -> None:
46
+ self.begin_calls += 1
47
+
48
+ def commit(self) -> None:
49
+ self.commit_calls += 1
50
+
51
+ def rollback(self) -> None:
52
+ self.rollback_calls += 1
53
+
54
+
55
+ class _AdapterStub:
56
+ def __init__(self) -> None:
57
+ self.set_statement_timeout_calls = []
58
+
59
+ def set_statement_timeout(self, conn: object, timeout_ms: int) -> None:
60
+ self.set_statement_timeout_calls.append(timeout_ms)
61
+
62
+ def describe_table(self, conn: object, namespace: str, table_name: str):
63
+ return {
64
+ "columns": [
65
+ {"column_name": "id"},
66
+ {"column_name": "name"},
67
+ {"column_name": "status"},
68
+ ],
69
+ "indexes": [],
70
+ }
71
+
72
+ def build_insert_query(self, namespace: str, table_name: str, columns):
73
+ return f"insert {namespace}.{table_name} ({','.join(columns)})"
74
+
75
+
76
+ class _RegistryStub:
77
+ def __init__(self, config: ConnectionConfig, adapter: object, conn: object) -> None:
78
+ self._config = config
79
+ self._adapter = adapter
80
+ self._conn = conn
81
+
82
+ def get_connection_config(self, connection_id: str) -> ConnectionConfig:
83
+ if connection_id != self._config.connection_id:
84
+ raise AssertionError(connection_id)
85
+ return self._config
86
+
87
+ @contextmanager
88
+ def connection_from_config(self, config: ConnectionConfig):
89
+ if config != self._config:
90
+ raise AssertionError(config)
91
+ yield self._conn, self._adapter
92
+
93
+
94
+ class TableFileImporterTestCase(unittest.TestCase):
95
+ def test_import_csv_inserts_header_subset(self) -> None:
96
+ with tempfile.TemporaryDirectory() as temp_dir:
97
+ csv_path = _write_csv(
98
+ Path(temp_dir) / "users.csv",
99
+ [["name", "status"], ["Alice", "active"], ["Bob", "disabled"]],
100
+ )
101
+ conn = _ConnectionStub()
102
+ log_path = Path(temp_dir) / "audit.jsonl"
103
+ importer = _build_importer(log_path, conn)
104
+
105
+ result = importer.import_table_file(
106
+ "crm_mysql_prod_main_rw",
107
+ "users",
108
+ str(csv_path),
109
+ )
110
+
111
+ records = _read_audit_records(log_path)
112
+
113
+ self.assertEqual(2, result["inserted_row_count"])
114
+ self.assertEqual(".csv", result["file_extension"])
115
+ self.assertEqual("crm", result["database"])
116
+ self.assertEqual(1, conn.begin_calls)
117
+ self.assertEqual(1, conn.commit_calls)
118
+ self.assertEqual(0, conn.rollback_calls)
119
+ self.assertEqual(
120
+ [("insert crm.users (name,status)", [("Alice", "active"), ("Bob", "disabled")])],
121
+ conn.cursor_stub.executed_many,
122
+ )
123
+ self.assertEqual("import_table_file", records[0]["tool"])
124
+ self.assertEqual(2, records[0]["row_count"])
125
+ self.assertEqual(".csv", records[0]["extra"]["file_extension"])
126
+
127
+ def test_import_csv_strips_utf8_bom_from_first_header(self) -> None:
128
+ with tempfile.TemporaryDirectory() as temp_dir:
129
+ csv_path = _write_csv(
130
+ Path(temp_dir) / "users.csv",
131
+ [["id", "name"], ["1", "Alice"]],
132
+ encoding="utf-8-sig",
133
+ )
134
+ conn = _ConnectionStub()
135
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
136
+
137
+ result = importer.import_table_file(
138
+ "crm_mysql_prod_main_rw",
139
+ "users",
140
+ str(csv_path),
141
+ )
142
+
143
+ self.assertEqual(1, result["inserted_row_count"])
144
+ self.assertEqual(
145
+ [("insert crm.users (id,name)", [("1", "Alice")])],
146
+ conn.cursor_stub.executed_many,
147
+ )
148
+
149
+ def test_import_csv_rejects_unknown_header_before_insert(self) -> None:
150
+ with tempfile.TemporaryDirectory() as temp_dir:
151
+ csv_path = _write_csv(Path(temp_dir) / "users.csv", [["missing"], ["x"]])
152
+ conn = _ConnectionStub()
153
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
154
+
155
+ with self.assertRaises(QueryExecutionError):
156
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
157
+
158
+ self.assertEqual([], conn.cursor_stub.executed_many)
159
+ self.assertEqual(0, conn.begin_calls)
160
+
161
+ def test_import_csv_rejects_duplicate_header_before_insert(self) -> None:
162
+ with tempfile.TemporaryDirectory() as temp_dir:
163
+ csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name", "name"], ["x", "y"]])
164
+ conn = _ConnectionStub()
165
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
166
+
167
+ with self.assertRaises(QueryExecutionError):
168
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
169
+
170
+ self.assertEqual([], conn.cursor_stub.executed_many)
171
+ self.assertEqual(0, conn.begin_calls)
172
+
173
+ def test_import_csv_rejects_empty_header_before_insert(self) -> None:
174
+ with tempfile.TemporaryDirectory() as temp_dir:
175
+ csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name", ""], ["x", "y"]])
176
+ conn = _ConnectionStub()
177
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
178
+
179
+ with self.assertRaises(QueryExecutionError):
180
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
181
+
182
+ self.assertEqual([], conn.cursor_stub.executed_many)
183
+ self.assertEqual(0, conn.begin_calls)
184
+
185
+ def test_import_file_rejects_unsupported_extension_before_insert(self) -> None:
186
+ with tempfile.TemporaryDirectory() as temp_dir:
187
+ txt_path = Path(temp_dir) / "users.txt"
188
+ txt_path.write_text("name\nAlice\n", encoding="utf-8")
189
+ conn = _ConnectionStub()
190
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
191
+
192
+ with self.assertRaises(QueryExecutionError):
193
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(txt_path))
194
+
195
+ self.assertEqual([], conn.cursor_stub.executed_many)
196
+ self.assertEqual(0, conn.begin_calls)
197
+
198
+ def test_import_csv_rejects_file_without_data_rows_before_insert(self) -> None:
199
+ with tempfile.TemporaryDirectory() as temp_dir:
200
+ csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name"]])
201
+ conn = _ConnectionStub()
202
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
203
+
204
+ with self.assertRaises(QueryExecutionError):
205
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
206
+
207
+ self.assertEqual([], conn.cursor_stub.executed_many)
208
+ self.assertEqual(0, conn.begin_calls)
209
+
210
+ def test_import_rolls_back_when_database_insert_fails(self) -> None:
211
+ with tempfile.TemporaryDirectory() as temp_dir:
212
+ csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name"], ["Alice"]])
213
+ conn = _ConnectionStub(RuntimeError("dsn://user:secret@db failed"))
214
+ log_path = Path(temp_dir) / "audit.jsonl"
215
+ importer = _build_importer(log_path, conn)
216
+
217
+ with self.assertRaises(QueryExecutionError) as caught:
218
+ importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
219
+
220
+ records = _read_audit_records(log_path)
221
+
222
+ self.assertIn("dsn://user:***@db failed", str(caught.exception))
223
+ self.assertEqual(1, conn.begin_calls)
224
+ self.assertEqual(0, conn.commit_calls)
225
+ self.assertEqual(1, conn.rollback_calls)
226
+ self.assertFalse(records[0]["success"])
227
+
228
+ def test_import_xlsx_uses_first_sheet_by_default(self) -> None:
229
+ with tempfile.TemporaryDirectory() as temp_dir:
230
+ xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
231
+ conn = _ConnectionStub()
232
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
233
+
234
+ result = importer.import_table_file(
235
+ "crm_mysql_prod_main_rw",
236
+ "users",
237
+ str(xlsx_path),
238
+ )
239
+
240
+ self.assertEqual("First", result["sheet_name"])
241
+ self.assertEqual(1, result["inserted_row_count"])
242
+ self.assertEqual(
243
+ [("insert crm.users (name)", [("Alice",)])],
244
+ conn.cursor_stub.executed_many,
245
+ )
246
+
247
+ def test_import_xlsx_uses_named_sheet_when_provided(self) -> None:
248
+ with tempfile.TemporaryDirectory() as temp_dir:
249
+ xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
250
+ conn = _ConnectionStub()
251
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
252
+
253
+ result = importer.import_table_file(
254
+ "crm_mysql_prod_main_rw",
255
+ "users",
256
+ str(xlsx_path),
257
+ sheet_name="Data",
258
+ )
259
+
260
+ self.assertEqual("Data", result["sheet_name"])
261
+ self.assertEqual(1, result["inserted_row_count"])
262
+ self.assertEqual(
263
+ [("insert crm.users (status)", [("active",)])],
264
+ conn.cursor_stub.executed_many,
265
+ )
266
+
267
+ def test_import_xlsx_rejects_missing_sheet_before_insert(self) -> None:
268
+ with tempfile.TemporaryDirectory() as temp_dir:
269
+ xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
270
+ conn = _ConnectionStub()
271
+ importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
272
+
273
+ with self.assertRaises(QueryExecutionError):
274
+ importer.import_table_file(
275
+ "crm_mysql_prod_main_rw",
276
+ "users",
277
+ str(xlsx_path),
278
+ sheet_name="Missing",
279
+ )
280
+
281
+ self.assertEqual([], conn.cursor_stub.executed_many)
282
+ self.assertEqual(0, conn.begin_calls)
283
+
284
+
285
+ def _build_importer(log_path: Path, conn: _ConnectionStub) -> TableFileImporter:
286
+ config = ConnectionConfig(
287
+ connection_id="crm_mysql_prod_main_rw",
288
+ engine="mysql",
289
+ label="CRM MySQL",
290
+ env="prod",
291
+ tenant="main",
292
+ role="rw",
293
+ dsn_env="MYSQL_CONN",
294
+ enabled=True,
295
+ default_database="crm",
296
+ )
297
+ return TableFileImporter(
298
+ registry=_RegistryStub(config, _AdapterStub(), conn),
299
+ settings=ServerSettings(audit_log_path=log_path),
300
+ audit_logger=AuditLogger(log_path),
301
+ )
302
+
303
+
304
+ def _write_csv(path: Path, rows: list[list[str]], encoding: str = "utf-8") -> Path:
305
+ with path.open("w", encoding=encoding, newline="") as handle:
306
+ writer = csv.writer(handle)
307
+ writer.writerows(rows)
308
+ return path
309
+
310
+
311
+ def _write_xlsx(path: Path) -> Path:
312
+ workbook = Workbook()
313
+ first = workbook.active
314
+ assert first is not None
315
+ first.title = "First"
316
+ first.append(["name"])
317
+ first.append(["Alice"])
318
+ second = workbook.create_sheet("Data")
319
+ second.append(["status"])
320
+ second.append(["active"])
321
+ workbook.save(path)
322
+ return path
323
+
324
+
325
+ def _read_audit_records(path: Path) -> list[dict]:
326
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
327
+
328
+
329
+ if __name__ == "__main__":
330
+ unittest.main()
@@ -73,6 +73,26 @@ class ValidatorTestCase(unittest.TestCase):
73
73
  )
74
74
  self.assertEqual({"query_block": {"select_id": 1}}, plan)
75
75
 
76
+ def test_postgres_build_insert_query_quotes_identifiers(self) -> None:
77
+ query = PostgresAdapter().build_insert_query(
78
+ "public", "orders", ["order", "status"]
79
+ )
80
+
81
+ self.assertEqual(
82
+ 'INSERT INTO "public"."orders" ("order", "status") VALUES (%s, %s)',
83
+ query.as_string(None),
84
+ )
85
+
86
+ def test_mysql_build_insert_query_quotes_identifiers(self) -> None:
87
+ query = MySQLAdapter().build_insert_query(
88
+ "crm", "orders", ["order", "status"]
89
+ )
90
+
91
+ self.assertEqual(
92
+ "INSERT INTO `crm`.`orders` (`order`, `status`) VALUES (%s, %s)",
93
+ query,
94
+ )
95
+
76
96
  def test_mysql_indexes_are_normalized(self) -> None:
77
97
  indexes = MySQLAdapter()._normalize_indexes(
78
98
  [
File without changes
File without changes