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 +3 -0
- sqlnow_mcp/cli.py +545 -0
- sqlnow_mcp/config.py +100 -0
- sqlnow_mcp/db.py +827 -0
- sqlnow_mcp/metadata.py +668 -0
- sqlnow_mcp/resource_limits.py +37 -0
- sqlnow_mcp/server.py +445 -0
- sqlnow_mcp/table_result.py +246 -0
- sqlnow_mcp/ui/table.html +355 -0
- sqlnow_mcp/ui.py +21 -0
- sqlnow_mcp-0.1.0.dist-info/METADATA +341 -0
- sqlnow_mcp-0.1.0.dist-info/RECORD +15 -0
- sqlnow_mcp-0.1.0.dist-info/WHEEL +4 -0
- sqlnow_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- sqlnow_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlnow_mcp/__init__.py
ADDED
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
|