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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnow-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: MCP server for DuckDB with interactive table viewer
5
5
  Keywords: mcp,duckdb,sql,llm,claude,cursor
6
6
  Author: David Raznick
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlnow-mcp"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "MCP server for DuckDB with interactive table viewer"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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/write, data directory) or 'publish' (read-only, metadata).",
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 = False,
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._close_connection()
67
- self.conn = duckdb.connect(str(db_path))
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
- conn.execute(sql)
193
- return {"name": table_name, "path": str(resolved), "mode": mode}
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
- self.attachments.append(entry)
221
- self._save_sidecar()
222
- return entry
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 {"name": name, "detached": True, "type": "database"}
213
+ return entry
232
214
 
233
- quoted = self._quote_ident(name)
234
- conn.execute(f"DROP VIEW IF EXISTS {quoted}")
235
- conn.execute(f"DROP TABLE IF EXISTS {quoted}")
236
- return {"name": name, "detached": True, "type": "file"}
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
- result = self._run_sql(sql.strip().rstrip(";"))
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
- execute_sql() returns a text summary only (no table viewer).
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