sqlnow-mcp 0.2.0__tar.gz → 0.2.1__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.
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/PKG-INFO +1 -1
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/pyproject.toml +1 -1
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/cli.py +1 -1
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/db.py +78 -40
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/server.py +3 -1
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/LICENSE +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/README.md +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/__init__.py +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/config.py +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/metadata.py +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/resource_limits.py +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/table_result.py +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/ui/table.html +0 -0
- {sqlnow_mcp-0.2.0 → sqlnow_mcp-0.2.1}/sqlnow_mcp/ui.py +0 -0
|
@@ -53,7 +53,7 @@ def _make_session(
|
|
|
53
53
|
@click.option(
|
|
54
54
|
"--mode",
|
|
55
55
|
type=click.Choice(["local", "publish"], case_sensitive=False),
|
|
56
|
-
help="Run as MCP server: 'local' (read
|
|
56
|
+
help="Run as MCP server: 'local' (read-only by default, temporary writes via execute_sql) or 'publish' (read-only, metadata).",
|
|
57
57
|
)
|
|
58
58
|
@click.option(
|
|
59
59
|
"--data-dir",
|
|
@@ -6,12 +6,13 @@ import threading
|
|
|
6
6
|
from datetime import date, datetime, time, timedelta
|
|
7
7
|
from decimal import Decimal
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Literal
|
|
9
|
+
from typing import Any, Callable, Literal, TypeVar
|
|
10
10
|
|
|
11
11
|
import duckdb
|
|
12
12
|
|
|
13
13
|
AttachMode = Literal["view", "load"]
|
|
14
14
|
DbType = Literal["POSTGRES", "SQLITE", "MYSQL"]
|
|
15
|
+
T = TypeVar("T")
|
|
15
16
|
|
|
16
17
|
MAX_ROWS = 100_000
|
|
17
18
|
BACKGROUND_CHUNK_ROWS = 2_000
|
|
@@ -28,7 +29,7 @@ class DuckDBSession:
|
|
|
28
29
|
allow_paths: list[Path] | tuple[Path, ...] = (),
|
|
29
30
|
allow_external: bool = False,
|
|
30
31
|
*,
|
|
31
|
-
read_only: bool =
|
|
32
|
+
read_only: bool = True,
|
|
32
33
|
query_timeout_sec: float | None = None,
|
|
33
34
|
) -> None:
|
|
34
35
|
self.data_dir = data_dir.resolve()
|
|
@@ -63,23 +64,8 @@ class DuckDBSession:
|
|
|
63
64
|
if not db_path.exists():
|
|
64
65
|
raise DuckDBSessionError(f"Database not found: {db_name}")
|
|
65
66
|
|
|
66
|
-
self.
|
|
67
|
-
self.
|
|
68
|
-
self._install_extensions(self.conn)
|
|
69
|
-
self.active_db = db_path
|
|
70
|
-
self.attachments = []
|
|
71
|
-
self.failed_attachments = []
|
|
72
|
-
|
|
73
|
-
sidecar = self._load_sidecar(db_path)
|
|
74
|
-
for entry in sidecar.get("attachments", []):
|
|
75
|
-
try:
|
|
76
|
-
if not self._is_attached(entry["name"]):
|
|
77
|
-
self._attach_from_sidecar(entry)
|
|
78
|
-
self.attachments.append(entry)
|
|
79
|
-
except Exception as exc:
|
|
80
|
-
failed = dict(entry)
|
|
81
|
-
failed["error"] = str(exc)
|
|
82
|
-
self.failed_attachments.append(failed)
|
|
67
|
+
self._open_database_connection(db_path, read_only=self.read_only)
|
|
68
|
+
self._load_database_attachments(db_path)
|
|
83
69
|
|
|
84
70
|
return {
|
|
85
71
|
"name": db_path.stem,
|
|
@@ -182,15 +168,19 @@ class DuckDBSession:
|
|
|
182
168
|
name: str | None = None,
|
|
183
169
|
mode: AttachMode = "view",
|
|
184
170
|
) -> dict[str, Any]:
|
|
185
|
-
conn = self._require_conn()
|
|
186
171
|
resolved = self._resolve_allowed_path(path)
|
|
187
172
|
if not resolved.exists():
|
|
188
173
|
raise DuckDBSessionError(f"File not found: {path}")
|
|
189
174
|
|
|
190
175
|
table_name = name or resolved.stem
|
|
191
176
|
sql = self._file_attach_sql(resolved, table_name, mode)
|
|
192
|
-
|
|
193
|
-
|
|
177
|
+
|
|
178
|
+
def attach() -> dict[str, Any]:
|
|
179
|
+
conn = self._require_conn()
|
|
180
|
+
conn.execute(sql)
|
|
181
|
+
return {"name": table_name, "path": str(resolved), "mode": mode}
|
|
182
|
+
|
|
183
|
+
return self._with_temporary_write_connection(attach)
|
|
194
184
|
|
|
195
185
|
def attach_database(
|
|
196
186
|
self,
|
|
@@ -201,13 +191,11 @@ class DuckDBSession:
|
|
|
201
191
|
if not self.allow_external:
|
|
202
192
|
raise DuckDBSessionError("External database attachments are disabled")
|
|
203
193
|
|
|
204
|
-
conn = self._require_conn()
|
|
205
194
|
db_type, attach_str = self._parse_database_connection(connection_string)
|
|
206
195
|
sql = (
|
|
207
196
|
f"ATTACH '{self._escape_sql_string(attach_str)}' "
|
|
208
197
|
f"AS {self._quote_ident(name)} (TYPE {db_type})"
|
|
209
198
|
)
|
|
210
|
-
conn.execute(sql)
|
|
211
199
|
|
|
212
200
|
entry: dict[str, Any] = {
|
|
213
201
|
"name": name,
|
|
@@ -217,26 +205,40 @@ class DuckDBSession:
|
|
|
217
205
|
if tables:
|
|
218
206
|
entry["tables"] = tables
|
|
219
207
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def detach_source(self, name: str) -> dict[str, Any]:
|
|
225
|
-
conn = self._require_conn()
|
|
226
|
-
attachment = next((a for a in self.attachments if a["name"] == name), None)
|
|
227
|
-
if attachment is not None:
|
|
228
|
-
conn.execute(f"DETACH {self._quote_ident(name)}")
|
|
229
|
-
self.attachments = [a for a in self.attachments if a["name"] != name]
|
|
208
|
+
def attach() -> dict[str, Any]:
|
|
209
|
+
conn = self._require_conn()
|
|
210
|
+
conn.execute(sql)
|
|
211
|
+
self.attachments.append(entry)
|
|
230
212
|
self._save_sidecar()
|
|
231
|
-
return
|
|
213
|
+
return entry
|
|
232
214
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
215
|
+
return self._with_temporary_write_connection(attach)
|
|
216
|
+
|
|
217
|
+
def detach_source(self, name: str) -> dict[str, Any]:
|
|
218
|
+
def detach() -> dict[str, Any]:
|
|
219
|
+
conn = self._require_conn()
|
|
220
|
+
attachment = next((a for a in self.attachments if a["name"] == name), None)
|
|
221
|
+
if attachment is not None:
|
|
222
|
+
conn.execute(f"DETACH {self._quote_ident(name)}")
|
|
223
|
+
self.attachments = [a for a in self.attachments if a["name"] != name]
|
|
224
|
+
self._save_sidecar()
|
|
225
|
+
return {"name": name, "detached": True, "type": "database"}
|
|
226
|
+
|
|
227
|
+
quoted = self._quote_ident(name)
|
|
228
|
+
conn.execute(f"DROP VIEW IF EXISTS {quoted}")
|
|
229
|
+
conn.execute(f"DROP TABLE IF EXISTS {quoted}")
|
|
230
|
+
return {"name": name, "detached": True, "type": "file"}
|
|
231
|
+
|
|
232
|
+
return self._with_temporary_write_connection(detach)
|
|
237
233
|
|
|
238
234
|
def run_mutating_query(self, sql: str) -> dict[str, Any]:
|
|
239
|
-
|
|
235
|
+
stripped = sql.strip().rstrip(";")
|
|
236
|
+
if self.read_only and self.active_db is not None:
|
|
237
|
+
return self._with_temporary_write_connection(
|
|
238
|
+
lambda: self._format_execution_result(self._run_sql(stripped))
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
result = self._run_sql(stripped)
|
|
240
242
|
return self._format_execution_result(result)
|
|
241
243
|
|
|
242
244
|
def run_query(self, sql: str, limit: int = 500) -> dict[str, Any]:
|
|
@@ -510,6 +512,42 @@ class DuckDBSession:
|
|
|
510
512
|
self._query_view_ids = []
|
|
511
513
|
self._query_meta = {}
|
|
512
514
|
|
|
515
|
+
def _open_database_connection(self, db_path: Path, *, read_only: bool) -> None:
|
|
516
|
+
self._close_connection()
|
|
517
|
+
self.conn = duckdb.connect(str(db_path), read_only=read_only)
|
|
518
|
+
self._install_extensions(self.conn)
|
|
519
|
+
self.active_db = db_path
|
|
520
|
+
self.attachments = []
|
|
521
|
+
self.failed_attachments = []
|
|
522
|
+
|
|
523
|
+
def _load_database_attachments(self, db_path: Path) -> None:
|
|
524
|
+
sidecar = self._load_sidecar(db_path)
|
|
525
|
+
for entry in sidecar.get("attachments", []):
|
|
526
|
+
try:
|
|
527
|
+
if not self._is_attached(entry["name"]):
|
|
528
|
+
self._attach_from_sidecar(entry)
|
|
529
|
+
self.attachments.append(entry)
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
failed = dict(entry)
|
|
532
|
+
failed["error"] = str(exc)
|
|
533
|
+
self.failed_attachments.append(failed)
|
|
534
|
+
|
|
535
|
+
def _with_temporary_write_connection(self, operation: Callable[[], T]) -> T:
|
|
536
|
+
if not self.read_only or self.active_db is None:
|
|
537
|
+
return operation()
|
|
538
|
+
|
|
539
|
+
db_path = self.active_db
|
|
540
|
+
if db_path is None:
|
|
541
|
+
raise DuckDBSessionError("No active database connection")
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
self._open_database_connection(db_path, read_only=False)
|
|
545
|
+
self._load_database_attachments(db_path)
|
|
546
|
+
return operation()
|
|
547
|
+
finally:
|
|
548
|
+
self._open_database_connection(db_path, read_only=True)
|
|
549
|
+
self._load_database_attachments(db_path)
|
|
550
|
+
|
|
513
551
|
@staticmethod
|
|
514
552
|
def _new_query_id() -> str:
|
|
515
553
|
return secrets.token_hex(6)
|
|
@@ -75,7 +75,9 @@ Use only for smallish results — keep ``limit`` low and aggregate in SQL when \
|
|
|
75
75
|
possible. Large payloads are truncated or rejected; use ``run_query`` for bigger \
|
|
76
76
|
result sets the user should browse.
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Persistent database sessions are opened read-only by default. execute_sql() briefly
|
|
79
|
+
reopens the active database read/write for that statement, then restores the
|
|
80
|
+
read-only connection. execute_sql() returns a text summary only (no table viewer).
|
|
79
81
|
|
|
80
82
|
Session state persists across tool calls. Call use_database or use_memory first \
|
|
81
83
|
before list_tables, run_query, run_query_json, execute_sql, or other tools that \
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|