sqlnow-mcp 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sqlnow_mcp.cli import main
2
+
3
+ __all__ = ["main"]
sqlnow_mcp/cli.py ADDED
@@ -0,0 +1,545 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from sqlnow_mcp.config import LocalConfig, PublishConfig, create_session, default_allow_paths
12
+ from sqlnow_mcp.db import DuckDBSession, DuckDBSessionError
13
+ from sqlnow_mcp.metadata import build_metadata_template, generate_stats, write_stats
14
+ from sqlnow_mcp.server import run_local_server, run_publish_server
15
+
16
+
17
+ def _session_from_ctx(ctx: click.Context) -> DuckDBSession:
18
+ return ctx.ensure_object(dict)["session"]
19
+
20
+
21
+ def _require_database(ctx: click.Context) -> str:
22
+ db = ctx.ensure_object(dict).get("database")
23
+ if not db:
24
+ raise click.ClickException("--database / -d is required for this command")
25
+ return db
26
+
27
+
28
+ def _open_database(session: DuckDBSession, db_name: str) -> dict[str, Any]:
29
+ try:
30
+ return session.use_database(db_name)
31
+ except DuckDBSessionError as exc:
32
+ raise click.ClickException(str(exc)) from exc
33
+
34
+
35
+ def _make_session(
36
+ data_dir: Path,
37
+ allow_paths: tuple[Path, ...],
38
+ allow_external: bool,
39
+ ) -> DuckDBSession:
40
+ try:
41
+ return create_session(
42
+ LocalConfig(
43
+ data_dir=data_dir,
44
+ allow_paths=allow_paths,
45
+ allow_external=allow_external,
46
+ )
47
+ )
48
+ except DuckDBSessionError as exc:
49
+ raise click.ClickException(str(exc)) from exc
50
+
51
+
52
+ @click.group(invoke_without_command=True)
53
+ @click.option(
54
+ "--mode",
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).",
57
+ )
58
+ @click.option(
59
+ "--data-dir",
60
+ type=click.Path(file_okay=False, path_type=Path),
61
+ default=".",
62
+ show_default=True,
63
+ help="Data directory for local mode and dev subcommands (not used by publish startup).",
64
+ )
65
+ @click.option(
66
+ "--allow-path",
67
+ "allow_paths",
68
+ multiple=True,
69
+ type=click.Path(path_type=Path),
70
+ help="Extra directory allowed for attach-file in local mode (repeatable; default: home).",
71
+ )
72
+ @click.option(
73
+ "--allow-external/--no-allow-external",
74
+ default=True,
75
+ show_default=True,
76
+ help="Allow attach-database in local mode (Postgres, SQLite, MySQL).",
77
+ )
78
+ @click.option(
79
+ "--host",
80
+ default="127.0.0.1",
81
+ show_default=True,
82
+ help="Bind address for HTTP transports (local and publish).",
83
+ )
84
+ @click.option(
85
+ "--port",
86
+ default=8000,
87
+ show_default=True,
88
+ type=int,
89
+ help="Port for HTTP transports (local and publish).",
90
+ )
91
+ @click.option(
92
+ "--transport",
93
+ type=click.Choice(
94
+ ["stdio", "http", "streamable-http", "sse"],
95
+ case_sensitive=False,
96
+ ),
97
+ default="stdio",
98
+ show_default=True,
99
+ help="MCP transport for local and publish (stdio spawns process; others listen on --host/--port).",
100
+ )
101
+ @click.option(
102
+ "--database",
103
+ "-d",
104
+ help="Active database name (without .db extension).",
105
+ )
106
+ @click.option(
107
+ "--db",
108
+ "publish_db",
109
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
110
+ help="DuckDB file for publish mode.",
111
+ )
112
+ @click.option(
113
+ "--metadata",
114
+ "metadata_path",
115
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
116
+ help="metadata.yaml for publish mode.",
117
+ )
118
+ @click.option(
119
+ "--stats",
120
+ "stats_path",
121
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
122
+ help="Precomputed stats.json for publish mode.",
123
+ )
124
+ @click.option(
125
+ "--strict-metadata/--no-strict-metadata",
126
+ default=False,
127
+ show_default=True,
128
+ help="Fail publish startup on metadata/DB mismatches.",
129
+ )
130
+ @click.option(
131
+ "--query-timeout",
132
+ type=float,
133
+ default=10.0,
134
+ show_default=True,
135
+ help="Per-query timeout in seconds (publish mode).",
136
+ )
137
+ @click.option(
138
+ "--memory-limit",
139
+ default="50%",
140
+ show_default=True,
141
+ help="DuckDB memory_limit for publish mode (e.g. 50%%, 2GiB).",
142
+ )
143
+ @click.option(
144
+ "--threads",
145
+ type=int,
146
+ default=1,
147
+ show_default=True,
148
+ help="DuckDB worker threads for publish mode.",
149
+ )
150
+ @click.option(
151
+ "--max-temp-directory-size",
152
+ default=None,
153
+ help="Cap DuckDB temp spill size for publish mode (e.g. 10GiB).",
154
+ )
155
+ @click.pass_context
156
+ def cli(
157
+ ctx: click.Context,
158
+ mode: str | None,
159
+ data_dir: Path,
160
+ allow_paths: tuple[Path, ...],
161
+ allow_external: bool,
162
+ host: str,
163
+ port: int,
164
+ transport: str,
165
+ database: str | None,
166
+ publish_db: Path | None,
167
+ metadata_path: Path | None,
168
+ stats_path: Path | None,
169
+ strict_metadata: bool,
170
+ query_timeout: float,
171
+ memory_limit: str,
172
+ threads: int,
173
+ max_temp_directory_size: str | None,
174
+ ) -> None:
175
+ """sqlnow-mcp — DuckDB MCP server and dev CLI."""
176
+ effective_allow_paths = default_allow_paths(allow_paths)
177
+
178
+ if ctx.invoked_subcommand is None:
179
+ if mode is None:
180
+ click.echo(ctx.get_help())
181
+ return
182
+ mode_lower = mode.lower()
183
+ if mode_lower == "publish":
184
+ if publish_db is None:
185
+ raise click.ClickException("Publish mode requires --db")
186
+ if metadata_path is None:
187
+ raise click.ClickException("Publish mode requires --metadata")
188
+ cfg = PublishConfig(
189
+ db_path=publish_db,
190
+ metadata_path=metadata_path,
191
+ stats_path=stats_path,
192
+ host=host,
193
+ port=port,
194
+ transport=transport.lower(), # type: ignore[arg-type]
195
+ strict_metadata=strict_metadata,
196
+ query_timeout_sec=query_timeout,
197
+ memory_limit=memory_limit,
198
+ threads=threads,
199
+ max_temp_directory_size=max_temp_directory_size,
200
+ )
201
+ try:
202
+ run_publish_server(cfg)
203
+ except DuckDBSessionError as exc:
204
+ raise click.ClickException(str(exc)) from exc
205
+ return
206
+ if mode_lower == "local":
207
+ cfg = LocalConfig(
208
+ data_dir=data_dir,
209
+ allow_paths=effective_allow_paths,
210
+ allow_external=allow_external,
211
+ host=host,
212
+ port=port,
213
+ transport=transport.lower(), # type: ignore[arg-type]
214
+ )
215
+ run_local_server(cfg)
216
+ return
217
+ raise click.ClickException(f"Unknown mode: {mode}")
218
+
219
+ ctx.ensure_object(dict)["session"] = _make_session(
220
+ data_dir, effective_allow_paths, allow_external
221
+ )
222
+ ctx.ensure_object(dict)["database"] = database
223
+
224
+
225
+ @cli.command("create-database")
226
+ @click.argument("name")
227
+ @click.pass_context
228
+ def create_database_cmd(ctx: click.Context, name: str) -> None:
229
+ """Create a new empty DuckDB database in data-dir."""
230
+ session = _session_from_ctx(ctx)
231
+ try:
232
+ result = session.create_database(name)
233
+ except DuckDBSessionError as exc:
234
+ raise click.ClickException(str(exc)) from exc
235
+ click.echo(json.dumps(result, indent=2))
236
+
237
+
238
+ @cli.command("list-databases")
239
+ @click.pass_context
240
+ def list_databases(ctx: click.Context) -> None:
241
+ """List .db files in data-dir."""
242
+ session = _session_from_ctx(ctx)
243
+ dbs = session.list_databases()
244
+ if not dbs:
245
+ click.echo("No databases found.")
246
+ return
247
+ for db in dbs:
248
+ click.echo(f"{db['name']}\t{db['size_mb']} MB\t{db['last_modified']}")
249
+
250
+
251
+ @cli.command("use-database")
252
+ @click.argument("name")
253
+ @click.pass_context
254
+ def use_database_cmd(ctx: click.Context, name: str) -> None:
255
+ """Open a database and print available tables."""
256
+ session = _session_from_ctx(ctx)
257
+ info = _open_database(session, name)
258
+ click.echo(json.dumps(info, indent=2))
259
+
260
+
261
+ @cli.command("current")
262
+ @click.pass_context
263
+ def current(ctx: click.Context) -> None:
264
+ """Show session state after opening --database."""
265
+ session = _session_from_ctx(ctx)
266
+ db = _require_database(ctx)
267
+ _open_database(session, db)
268
+ click.echo(json.dumps(session.current_database(), indent=2))
269
+
270
+
271
+ @cli.command("attach-file")
272
+ @click.argument("path", type=click.Path(path_type=Path))
273
+ @click.option("--name", help="Table/view name (default: file stem).")
274
+ @click.option(
275
+ "--mode",
276
+ "attach_mode",
277
+ type=click.Choice(["view", "load"], case_sensitive=False),
278
+ default="view",
279
+ show_default=True,
280
+ )
281
+ @click.pass_context
282
+ def attach_file_cmd(
283
+ ctx: click.Context,
284
+ path: Path,
285
+ name: str | None,
286
+ attach_mode: str,
287
+ ) -> None:
288
+ """Attach a CSV or Parquet file as a view or table."""
289
+ session = _session_from_ctx(ctx)
290
+ db = _require_database(ctx)
291
+ _open_database(session, db)
292
+ try:
293
+ result = session.attach_file(
294
+ str(path), name=name, mode=attach_mode.lower() # type: ignore[arg-type]
295
+ )
296
+ except DuckDBSessionError as exc:
297
+ raise click.ClickException(str(exc)) from exc
298
+ click.echo(json.dumps(result, indent=2))
299
+
300
+
301
+ @cli.command("attach-database")
302
+ @click.argument("connection_string")
303
+ @click.option("--name", required=True, help="Catalog name for ATTACH.")
304
+ @click.option(
305
+ "--table",
306
+ "tables",
307
+ multiple=True,
308
+ help="Limit exposed tables from the external database (repeatable).",
309
+ )
310
+ @click.pass_context
311
+ def attach_database_cmd(
312
+ ctx: click.Context,
313
+ connection_string: str,
314
+ name: str,
315
+ tables: tuple[str, ...],
316
+ ) -> None:
317
+ """Attach an external database (Postgres, SQLite, MySQL)."""
318
+ session = _session_from_ctx(ctx)
319
+ db = _require_database(ctx)
320
+ _open_database(session, db)
321
+ try:
322
+ result = session.attach_database(
323
+ connection_string,
324
+ name,
325
+ tables=list(tables) or None,
326
+ )
327
+ except DuckDBSessionError as exc:
328
+ raise click.ClickException(str(exc)) from exc
329
+ click.echo(json.dumps(result, indent=2))
330
+
331
+
332
+ @cli.command("detach")
333
+ @click.argument("name")
334
+ @click.pass_context
335
+ def detach(ctx: click.Context, name: str) -> None:
336
+ """Detach an external database or drop an attached file view/table."""
337
+ session = _session_from_ctx(ctx)
338
+ db = _require_database(ctx)
339
+ _open_database(session, db)
340
+ try:
341
+ result = session.detach_source(name)
342
+ except DuckDBSessionError as exc:
343
+ raise click.ClickException(str(exc)) from exc
344
+ click.echo(json.dumps(result, indent=2))
345
+
346
+
347
+ @cli.command("list-tables")
348
+ @click.pass_context
349
+ def list_tables_cmd(ctx: click.Context) -> None:
350
+ """List tables and columns in the active database."""
351
+ session = _session_from_ctx(ctx)
352
+ db = _require_database(ctx)
353
+ _open_database(session, db)
354
+ for table in session.list_tables():
355
+ cols = ", ".join(f"{c['name']}:{c['type']}" for c in table["columns"])
356
+ click.echo(f"{table['name']}\t{cols}")
357
+
358
+
359
+ @cli.command("describe")
360
+ @click.argument("table_name")
361
+ @click.pass_context
362
+ def describe(ctx: click.Context, table_name: str) -> None:
363
+ """Describe a table (JSON)."""
364
+ session = _session_from_ctx(ctx)
365
+ db = _require_database(ctx)
366
+ _open_database(session, db)
367
+ try:
368
+ result = session.describe_table(table_name)
369
+ except DuckDBSessionError as exc:
370
+ raise click.ClickException(str(exc)) from exc
371
+ click.echo(json.dumps(result, indent=2))
372
+
373
+
374
+ @cli.command("sample")
375
+ @click.argument("table_name")
376
+ @click.option("--limit", "-n", default=10, show_default=True)
377
+ @click.option(
378
+ "--output",
379
+ "-o",
380
+ type=click.Path(path_type=Path),
381
+ help="Write rows as CSV (default: JSON on stdout).",
382
+ )
383
+ @click.pass_context
384
+ def sample(
385
+ ctx: click.Context,
386
+ table_name: str,
387
+ limit: int,
388
+ output: Path | None,
389
+ ) -> None:
390
+ """Sample rows from a table."""
391
+ session = _session_from_ctx(ctx)
392
+ db = _require_database(ctx)
393
+ _open_database(session, db)
394
+ result = session.sample_table(table_name, n=limit)
395
+ rows = result["rows"]
396
+ if output is None:
397
+ click.echo(json.dumps(result, indent=2))
398
+ return
399
+ headers = list(rows[0].keys()) if rows else []
400
+ with output.open("w", newline="", encoding="utf-8") as f:
401
+ if headers:
402
+ writer = csv.DictWriter(f, fieldnames=headers)
403
+ writer.writeheader()
404
+ writer.writerows(rows)
405
+ click.echo(f"Wrote {len(rows)} rows to {output}")
406
+
407
+
408
+ @cli.command("query")
409
+ @click.argument("sql")
410
+ @click.option(
411
+ "--output",
412
+ "-o",
413
+ type=click.Path(path_type=Path),
414
+ help="Write query results as CSV (default: stdout).",
415
+ )
416
+ @click.option("--limit", default=500, show_default=True, help="Max rows to fetch.")
417
+ @click.pass_context
418
+ def query(ctx: click.Context, sql: str, output: Path | None, limit: int) -> None:
419
+ """Run SQL and write results as CSV to stdout or a file."""
420
+ session = _session_from_ctx(ctx)
421
+ db = _require_database(ctx)
422
+ _open_database(session, db)
423
+ try:
424
+ if DuckDBSession._is_mutating_sql(sql):
425
+ result = session.run_mutating_query(sql)
426
+ click.echo(result["message"])
427
+ return
428
+
429
+ result = session.run_query(sql, limit=limit)
430
+ except Exception as exc:
431
+ raise click.ClickException(str(exc)) from exc
432
+
433
+ if output is None:
434
+ writer = csv.writer(sys.stdout)
435
+ if result["headers"]:
436
+ writer.writerow(result["headers"])
437
+ writer.writerows(result["rows"])
438
+ return
439
+
440
+ with output.open("w", newline="", encoding="utf-8") as f:
441
+ writer = csv.writer(f)
442
+ if result["headers"]:
443
+ writer.writerow(result["headers"])
444
+ writer.writerows(result["rows"])
445
+ click.echo(f"Wrote {len(result['rows'])} rows to {output}")
446
+
447
+
448
+ @cli.command("generate-metadata")
449
+ @click.option(
450
+ "--output",
451
+ "-o",
452
+ type=click.Path(path_type=Path),
453
+ help="Write metadata.yaml to this path (default: stdout).",
454
+ )
455
+ @click.option("--title", help="Dataset title (default: derived from database name).")
456
+ @click.option(
457
+ "--include-attached/--no-include-attached",
458
+ default=False,
459
+ show_default=True,
460
+ help="Include tables from attached databases (default: native tables only).",
461
+ )
462
+ @click.pass_context
463
+ def generate_metadata_cmd(
464
+ ctx: click.Context,
465
+ output: Path | None,
466
+ title: str | None,
467
+ include_attached: bool,
468
+ ) -> None:
469
+ """Generate a starter metadata.yaml from the active database schema."""
470
+ session = _session_from_ctx(ctx)
471
+ db = _require_database(ctx)
472
+ _open_database(session, db)
473
+ try:
474
+ text = build_metadata_template(
475
+ session,
476
+ title=title,
477
+ include_attached=include_attached,
478
+ )
479
+ except (DuckDBSessionError, ValueError) as exc:
480
+ raise click.ClickException(str(exc)) from exc
481
+
482
+ if output is None:
483
+ click.echo(text, nl=False)
484
+ if not text.endswith("\n"):
485
+ click.echo()
486
+ return
487
+
488
+ output.write_text(text, encoding="utf-8")
489
+ click.echo(f"Wrote metadata template to {output}")
490
+
491
+
492
+ @cli.command("generate-stats")
493
+ @click.option(
494
+ "--output",
495
+ "-o",
496
+ type=click.Path(path_type=Path),
497
+ help="Write stats.json to this path (default: stdout).",
498
+ )
499
+ @click.option(
500
+ "--sample-threshold",
501
+ default=100_000,
502
+ show_default=True,
503
+ type=int,
504
+ help="Row count above which SUMMARIZE uses Bernoulli sampling.",
505
+ )
506
+ @click.option(
507
+ "--include-attached/--no-include-attached",
508
+ default=False,
509
+ show_default=True,
510
+ help="Profile tables from attached databases (default: native tables only).",
511
+ )
512
+ @click.pass_context
513
+ def generate_stats_cmd(
514
+ ctx: click.Context,
515
+ output: Path | None,
516
+ sample_threshold: int,
517
+ include_attached: bool,
518
+ ) -> None:
519
+ """Generate stats.json with SUMMARIZE profiles for publish mode."""
520
+ session = _session_from_ctx(ctx)
521
+ db = _require_database(ctx)
522
+ _open_database(session, db)
523
+ try:
524
+ stats = generate_stats(
525
+ session,
526
+ sample_threshold=sample_threshold,
527
+ include_attached=include_attached,
528
+ )
529
+ except (DuckDBSessionError, ValueError) as exc:
530
+ raise click.ClickException(str(exc)) from exc
531
+
532
+ if output is None:
533
+ click.echo(json.dumps(stats.to_dict(), indent=2, ensure_ascii=False))
534
+ return
535
+
536
+ write_stats(output, stats)
537
+ click.echo(f"Wrote stats for {len(stats.tables)} table(s) to {output}")
538
+
539
+
540
+ def main() -> None:
541
+ try:
542
+ cli(standalone_mode=True)
543
+ except click.ClickException as exc:
544
+ click.echo(str(exc), err=True)
545
+ sys.exit(1)
sqlnow_mcp/config.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from sqlnow_mcp.db import DuckDBSession, DuckDBSessionError
8
+ from sqlnow_mcp.metadata import (
9
+ MetadataContext,
10
+ load_metadata,
11
+ load_stats,
12
+ log_validation_report,
13
+ validate_metadata,
14
+ )
15
+ from sqlnow_mcp.resource_limits import resolve_memory_limit
16
+
17
+ FORBIDDEN_ALLOW_ROOTS = {Path("/"), Path()}
18
+
19
+ Transport = Literal["stdio", "http", "streamable-http", "sse"]
20
+
21
+
22
+ def default_allow_paths(explicit: tuple[Path, ...]) -> tuple[Path, ...]:
23
+ return explicit if explicit else (Path.home(),)
24
+
25
+
26
+ def reject_unsafe_allow_paths(paths: tuple[Path, ...]) -> None:
27
+ for path in paths:
28
+ resolved = path.resolve()
29
+ if resolved in FORBIDDEN_ALLOW_ROOTS:
30
+ raise DuckDBSessionError(
31
+ f"Refusing unsafe --allow-path {path!s} (would allow any file on disk)"
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LocalConfig:
37
+ data_dir: Path
38
+ allow_paths: tuple[Path, ...]
39
+ allow_external: bool
40
+ host: str = "127.0.0.1"
41
+ port: int = 8000
42
+ transport: Transport = "stdio"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PublishConfig:
47
+ db_path: Path
48
+ metadata_path: Path
49
+ stats_path: Path | None = None
50
+ host: str = "127.0.0.1"
51
+ port: int = 8000
52
+ transport: Transport = "stdio"
53
+ strict_metadata: bool = False
54
+ query_timeout_sec: float = 10.0
55
+ memory_limit: str = "50%"
56
+ threads: int = 1
57
+ max_temp_directory_size: str | None = None
58
+
59
+
60
+ def create_session(cfg: LocalConfig) -> DuckDBSession:
61
+ effective = default_allow_paths(cfg.allow_paths)
62
+ reject_unsafe_allow_paths(effective)
63
+ return DuckDBSession(
64
+ data_dir=cfg.data_dir,
65
+ allow_paths=effective,
66
+ allow_external=cfg.allow_external,
67
+ )
68
+
69
+
70
+ def create_publish_session(cfg: PublishConfig) -> tuple[DuckDBSession, MetadataContext]:
71
+ db_path = cfg.db_path.resolve()
72
+ if not db_path.exists():
73
+ raise DuckDBSessionError(f"Database not found: {db_path}")
74
+
75
+ memory_limit = resolve_memory_limit(cfg.memory_limit)
76
+ session = DuckDBSession(
77
+ data_dir=db_path.parent,
78
+ allow_paths=(),
79
+ allow_external=False,
80
+ read_only=True,
81
+ query_timeout_sec=cfg.query_timeout_sec,
82
+ )
83
+ session.open_publish_database(
84
+ db_path,
85
+ memory_limit=memory_limit,
86
+ threads=cfg.threads,
87
+ max_temp_directory_size=cfg.max_temp_directory_size,
88
+ )
89
+
90
+ meta = load_metadata(cfg.metadata_path.resolve())
91
+ stats = load_stats(cfg.stats_path.resolve() if cfg.stats_path else None)
92
+ meta_ctx = MetadataContext(meta, stats)
93
+
94
+ report = validate_metadata(meta, session, strict=cfg.strict_metadata)
95
+ log_validation_report(report)
96
+ if cfg.strict_metadata and report.has_errors():
97
+ messages = "; ".join(issue.message for issue in report.errors)
98
+ raise DuckDBSessionError(f"Metadata validation failed: {messages}")
99
+
100
+ return session, meta_ctx